Visualising Water Profiles

Posted on Sun 11 April 2021 in python

Here's the first post of a few planned within a fermentation series. One of the key ingredients to any drink, whether beer, kombucha, hop water etc is of course water, and not all waters are created equal. If water is the base of a drink, some flavours are better complimenteted by a blank canvas, while minerality can be used to make certain flavours pop. A common analogy is when cooking, flavours are brought out using salt, vinegar and other condiments. Getting this balance right can make all difference. Conversely if you're starting point is too rich in these properties, it's probably worth considering diluting, or even switching out for low-minerality bottled water. A common recommendation is to look in to the properties of your water at home, investigate what the properties of your water are, and see where you can go from there to tweak your water profile using common additives such as gypsum, baking soda and salt.

The subject can be confusing, and although I've spent some time studying rock chemistry, my brain isn't really wired to think in multi-dimensional planes. For this reason, I wanted to explore the potential to display the key information as a visualisation exercise. The base dataset are common source and target water profiles from popular brewing software [1][2]. In general, it can be said that city water profiles are most suitable with the style of beer for which they are famous, eg. Dublin and Stout (Guinness). These data are supplimented by my own water profile from a Barcelona city water report, and some common mineral waters profiles for reference. The data are reduced to three dimensions to allow plotting using the properties of water hardness (minerality: required for clarity, flavour and stability), alkalinity (low: minimal complexity, too high: lose flavour) and Sulfide/Chloride ratio (low: showcase malt, high: dry, crisp, to showcase bitterness). Unfortunately, I ran out of axis at this point, so Sodium (Na) couldn't be included in this analysis, but this is probably the most intuitive property: in general, we don't like to drink anything with too much salt (apologies to the Lassi and Gose fans).

In [6]:
import pandas as pd
import numpy as np

import matplotlib.pyplot as plt

# load data to pandas DataFrame, remove subheading and reset index
data = pd.read_csv(r'data\water_profiles.csv', header=0)
data =  data.drop(index=[0]).reset_index(drop=True)

# set relevant data to type: float
for c in ['Ca2+','Mg2+','Na+','Cl-','SO42-','HCO3-']:
    data[c] = pd.to_numeric(data[c])

# custom functions to derive water properties
def calc_hardness(ca2, mg2):
    return (ca2 / 0.401) + (mg2 / 0.243)

def calc_alkalinity(hco3):
    return hco3 / 1.22

def calc_so4_cl(so4, cl):
    if cl < 0.0001:
        return 0
    else:
        return so4 / cl

# apply functions to DataFrame
data['Hardness'] = data.apply(lambda x: calc_hardness(x['Ca2+'], x['Mg2+']), axis=1)
data['SO4:Chloride'] = data.apply(lambda x: calc_so4_cl(x['SO42-'], x['Cl-']), axis=1)
data['Alkalinity'] = data['HCO3-'].apply(calc_alkalinity)

# scatter plot
profile_type = {'Target':'green',
                'Mineral Water':'blue',
                'Source':'red'}

fig, ax = plt.subplots(figsize=(8,8))
for i, l in enumerate(data['Name']):
    x = data['Hardness'][i]
    y = data['Alkalinity'][i]
    z = (data['SO4:Chloride'][i] + 0.1) * 200
    c = profile_type[data['Type'][i]]
    plt.scatter(x, y, z, c, alpha=0.8, label=l)
    plt.text(x+0.3, y+0.3, l, fontsize=9)

ax.set_xlabel('Hardness')
ax.set_ylabel('Alkalinity')
plt.show()
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-6-bdd35e29e7f3> in <module>
     48 plt.show()
     49 
---> 50 import seaborn as sns
     51 sns.scatterplot(data=data, x='Hardness', y='Alkalinity', size='SO4:Chloride')

ModuleNotFoundError: No module named 'seaborn'

The graphic brought out a few observations I hadn't really thought of too much:

  • I wasn't convinced that my water profile was great, but Barcelona cities water is in good company, sharing a water profile with Edinburgh, and looks suitable for Ambers, Ales and Light Hoppy beers without modification.
  • Unfortunately my alkalinity is too high for most of my favourite styles including Pale Ale and IPA beers, so some potential for improvement over using ground water here.
  • While Burton (upon Trent) is world renouned for it's high minerality, Dortmund has even higher minerality!
  • Mineral water falls outside the alkalinty range of common water profiles (anything above 280 at a maximum generally isn't recommended), so be careful which bottled water you're selecting.

The next step was to take my starting water profile and see where the data point moves with the addition of common water additives (range of movement corresponds to 1g addition in 1L of water):

In [5]:
# 3d visualisation demonstrating influence of common water additives
fig = plt.figure(figsize=(10,10))
ax = fig.add_subplot(projection='3d', elev=30, azim=-60)

for i, l in enumerate(data['Name']):
    x = data['Hardness'][i]
    y = data['Alkalinity'][i]
    z = (data['SO4:Chloride'][i] + 0.1) * 200
    c = profile_type[data['Type'][i]]
    ax.scatter(x, y, z, marker='s', c=c)
    ax.text(x+1, y+1, z+1, l, fontsize=9)

# impact of adding common salts
# volume assumption -> 1 litre

                  # name    : ca, mg, na, cl, so4, hco3, total weight (inc H2O)
mineral_weights = {'GYPSUM': (40.08, 0.0, 0.0, 0.0, 96.06, 0.0, 172.18, 'pink'),
                   'CALC CL':(40.08, 0.0, 0.0, 70.90, 0.0, 0.0, 147.00, 'lightgreen'),
                   'EPSOM':  (0.0, 24.31, 0.0, 0.0, 96.06, 0.0, 246.47, 'cyan'),
                   'SALT':   (0.0, 0.0, 22.99, 35.45, 0.0, 0.0, 58.44, 'lightblue'),
                   'BAKING SODA': (0.0, 0.0, 22.99, 0.0, 0.0, 61.02, 84.01, 'plum')
                 }

idx = 2  # my tap water (barcelona)
const = 2/3
for m in list(mineral_weights.keys()):
    source = data.iloc[idx, 2:8]
    m1, m2, m3, m4, m5, m6, weight, col = mineral_weights[m]
    step = [(25 / weight) * const * m for m in [m1, m2, m3, m4, m5, m6]]
    for i in range(1, 40):  # 0.025 -> 1 g
        source += step
        x = calc_hardness(source['Ca2+'], source['Mg2+'])
        y = calc_alkalinity(source['HCO3-'])
        z = (calc_so4_cl(source['SO42-'], source['Cl-']) + 0.1) * 200
        ax.scatter(x, y, z, marker='.', c=col, alpha=0.5)
    ax.text(x+1, y+1, z+1, m, c='red', fontsize=9)

ax.set_xlabel('Hardness')
ax.set_ylabel('Alkalinity')
ax.set_zlabel('Sulfate:Chloride')
plt.show()

Using a single or combination of additives allows us to increase hardness, alkalinity or tweek the Sulfate/Chloride ratio. For the Barcelona water profile, this isn't often going to be helpful when fermenting, as the water already possesses strong hardness and alkalinity. However, the use of additives could be used to create our own mineral water, eg. adding Calcium Chloride to make San Pelligrino. For styles where soft water is required such as lagers, dilution or using using bottled distilled or reverse osmosis water is necessary, and any desired profile could be achieving using this starting point and the use of additives.

I enjoyed putting together these graphics and learned a lot about the subject. There's plenty of scope to continue this project through building an interative widgit to allow others to graphically understand there own water profile, so I might come back to this in the future.