Salty Brewing: Optimizing Mineral and Salt Additions for Home Brew Water Chemistry

By Paul English, Sun 13 November 2016, in category Misc

homebrewing, optimization, python

Beer brewing is a fun hobby and can in fact be quite the science. Brewers have made it very easy to get started, and there's quite a bit of reading available for anyone interested. After a few brews you might come across concepts of water chemistry and begin integrating it into your brew day repertoire.

Water from place to place is not the same. Water found where I'm at, in Salt Lake City, differs from that in London, or Germany, or elsewhere. Given that water is the primary ingredient in beer, it of course has a large effect on the flavor profile of the final product. It's common for brewers to make mineral and salt additions to their water trying to achieve a similar water profile to well known locations or to match the beer style they are brewing. They might even start from water run through reverse osmosis filters to get rid of initial minerals and build off a clean slate.

With a bit of reading and some popular online calculators it's easy for a novice to adjust their water after they've made a few batches. The simplest online calculator that I've found is the Brewer's Friend calculator. You add in your source water profile, and the target profile that you're looking for, and guess at the salt additions to make to get near your target profile.

This is great, but with a guess and update method we can only get so close, there must be a better way. Guessing at salts is a manual operation, let's optimize this.

Doing a search we use the following source water profile for Salt Lake City and a target profile for brewing a Porter:

# Ca, Mg, SO, Na, Cl, HCO
source = [30, 17, 12, 5, 7, 252]
target = [100, 5, 50, 35, 60, 265]

The first thing to do is to come up with a function that given salts and a source water profile, estimates what the resulting water profile will be. With a small bit of reverse engineering we can come up with the following:

import numpy as np
def adjust_water(source_water, salts, mash_volume):
    adjCa = 0
    adjMg = 0
    adjSO4 = 0
    adjNa = 0
    adjCl = 0
    adjHCO3 = 0

    CaCO3 = salts[0] / 2
    NaHCO3 = salts[1]
    CaSO4 = salts[2]
    CaCl2 = salts[3]
    MgSO4 = salts[4]
    NaCl = salts[5]

    if CaCO3  > 0:
        adjCa += ((105 * CaCO3) / mash_volume)
        adjHCO3 += ((321 * CaCO3) / mash_volume)
    if NaHCO3 > 0:
        adjNa = adjNa + ((75 * NaHCO3) / mash_volume);
        adjHCO3 = adjHCO3 + ((191 * NaHCO3) / mash_volume)
    if CaSO4 > 0:
        adjCa = adjCa + ((61.5 * CaSO4) / mash_volume);
        adjSO4 = adjSO4 + ((147.4 * CaSO4) / mash_volume)
    if CaCl2 > 0:
        adjCa = adjCa + ((72 * CaCl2) / mash_volume);
        adjCl = adjCl + ((127 * CaCl2) / mash_volume)
    if MgSO4 > 0:
        adjMg = adjMg + ((26 * MgSO4) / mash_volume);
        adjSO4 = adjSO4 + ((103 * MgSO4) / mash_volume)
    if NaCl > 0:
        adjNa = adjNa + ((104 * NaCl) / mash_volume);
        adjCl = adjCl + ((160 * NaCl) / mash_volume)

    return np.array([
        adjCa, adjMg, adjSO4, adjNa, adjCl, adjHCO3
    ]) + source_water

Let's test this and see if we get close enough to the calculator for just guessing at some salt additions that look good.

mash_volume = 5.5

# CaCO3, NaHCO3, CaSO4, CaCl2, MgSO4, NaCl
initial_adjustments = np.array([.5, .5, 1.6, 2.2, 0, .5])

# These are the values that the calculator generates, it's
# just here for reference.
expected_out = np.array([81, 17, 55, 21, 72, 284])

calculate(source, initial_adjustments, mash_volume)
# > array([  81.46363636,   17.        ,   54.88      ,   21.27272727,
#            72.34545455,  283.95454545])

This looks right, the calculator does a bit of rounding so the numbers aren't exact.

Ok, this is the bulk of the work, now we can import the right functions and setup the function we've got for optimization.

from scipy.optimize import minimize
from scipy.spatial.distance import euclidean

def to_optimize(source_water, target_water, mash_volume):
    def f(x):
        adjusted_water = adjust_water(source_water, x, mash_volume)
        return euclidean(adjusted_water, target_water)
    return f

We'll use two functions from scipy. minimize does exactly what you expect. Given a function of the right format, it will use an optimization routine to come up with some input $x$ that minimizes the output $y$. This is why we wrap our water adjustment function with a closure pattern. We want to be able to generate a function that takes just one input $x$ which will be an array of salt additions, thus we enclose the values for source_water, target_water, and mash_volume. The source water and mash volume are required for the water adjustment function, but what about the target water profile? We enclose this because we need some scalar output. The distance between our adjusted water and this target profile is exactly that. We use Euclidean distance $d(x,y) = \sqrt{\sum_{i=1}^n (x_i - y_i)^2}$ for $x,y \in \mathbb{R}^n$ which is arguably the most common vector distance metric out there.

res = minimize(to_optimize(source, target, mash_volume), initial_adjustments)
# >      fun: 32.065789859694725
#   hess_inv: array([[  5.56213775e-02,  -3.00115586e-02,   1.00650743e-02,
#           -3.22655558e-02,  -1.95928337e-04,   1.99052614e-02],
#         [ -3.00115586e-02,   2.52732613e-02,  -2.12226341e-03,
#            1.19919099e-02,  -1.23291127e-05,  -1.21397055e-02],
#         [  1.00650743e-02,  -2.12226341e-03,   4.13817077e-02,
#           -1.90201344e-02,   2.01076204e-04,   1.11549208e-02],
#         [ -3.22655558e-02,   1.19919099e-02,  -1.90201344e-02,
#            1.10745819e-01,  -1.27621051e-04,  -6.41900424e-02],
#         [ -1.95928337e-04,  -1.23291127e-05,   2.01076204e-04,
#           -1.27621051e-04,   1.38332275e-04,  -1.32332690e-05],
#         [  1.99052614e-02,  -1.21397055e-02,   1.11549208e-02,
#           -6.41900424e-02,  -1.32332690e-05,   6.50973546e-02]])
#        jac: array([  0.00000000e+00,  -4.76837158e-07,   4.76837158e-07,
#           9.53674316e-07,   0.00000000e+00,  -4.76837158e-07])
#    message: 'Optimization terminated successfully.'
#       nfev: 536
#        nit: 15
#       njev: 67
#     status: 0
#    success: True
#          x: array([ -1.07491790e-02,   5.64696573e-01,   1.71839728e+00,
#           2.40503324e+00,  -1.16764949e-04,   2.89023803e-01])

Now that we’ve formulated everything, we can generate our function to optimize, and start with the initial salt guesses that we came up with. From this we get a result and a lot of output. Cleaning it up.

final_adjustments = res.x.round(1)
# > array([-0. ,  0.6,  1.7,  2.4, -0. ,  0.3])

This looks great, we can just interpret those negative “zeros” as regular zeros (they’re rounded so we just can’t see that they’re small negative numbers).

Is this better?

print('Initial Salts Norm:', np.linalg.norm(adjust_water(source, initial_adjustments, mash_volume)))
print('Final Salts Norm:', np.linalg.norm(adjust_water(source, final_adjustments, mash_volume)))
print('Difference:', euclidean(
    adjust_water(source, initial_adjustments, mash_volume),
    adjust_water(source, final_adjustments, mash_volume)
# > Initial Salts Norm: 310.247830053
#   Final Salts Norm: 299.879226869
#   Difference: 11.796525752725286

The magnitude of our final additions are in fact smaller. This seems like a good choice, I think less is more and this value will generate a final water profile closer than our initial guess. Water chemistry in this manner is not exact, nor does it need to be.

There are more advanced calculators out there, and there is a lot that goes into making a perfect brew, I hope that this is a fun addition if you’re looking to get closer to a perfect water profile, and I welcome any comments about how this might affect a beer.

There are a lot of things that could be built off this. We didn’t add in the water alkalinity into our optimization. This is an important value to measure, and could also be added to our target water profile. Additionally it’s very likely that there are constraints that one would put on their target profile, and definitely on their salt additions. We glazed over it, but our optimizer generated negative values. Typically we only add minerals to our water, we don’t subtract them. Since these values were so close to zero we ignored them, but one can optimize with constraints. We’d obviously want our salts x to be above 0. For this simple hack I won’t worry about them, but for the next batch I’ll see if there’s anything worth improving.

Prost! Enjoy your beer, and comment if you can improve on this or want to teach me anything I missed.