Skip to content

Instantly share code, notes, and snippets.

@Cimbali
Last active October 20, 2022 08:32
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Cimbali/7bd5ad980bd0113f04276d726f2022e7 to your computer and use it in GitHub Desktop.
Save Cimbali/7bd5ad980bd0113f04276d726f2022e7 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
""" Priming sugar calculator
Computed from Robert McGill’s post Priming with sugar on byo.com
https://byo.com/article/priming-with-sugar/
Model for CO2 dissolved in beer by A. J. deLange,
fitted on data from ASBC’s Method of Analysis (MOA): Beer 13. Dissolved Carbon Dioxide
https://web.archive.org/web/20140327053255/http://hbd.org/ajdelange/Brewing_articles/CO2%20Volumes.pdf
ASBC data applies to beer with specific gravity of 1.010 and at equilibrium, though beer in a fermenter is likely supersaturated.
This means unless the beer is vigorously shaken or left to sit for weeks, the amount of dissolved CO2 is underestimated.
This script uses grams per liter to express dissolved CO2 quantities, whereas the cited sources use the volume
the CO2 would take up as a gas in standard temperature and pression (STP) conditions* per liter of beer.
* Both sources use for STP the pre-1982 IUPAC definition: 0°C and 1 atm (= 1013.25 mbar),
hence a gas molar volume of 22.414 L.
"""
__author__ = 'Cimbali <me@cimba.li>'
__copyright__ = '© 2022 Cimbali'
__license__ = 'MIT'
__version__ = '0.0.0'
import click
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
import itertools
import io
#: STP conversion between CO2 weight and CO2 volume at STP: 44 g/mol, and 22.414 L/mol at STP
co2_g_per_stp_vol = 44.01 / 22.414
# Molar weights:
# dextrose C6H12O6 180.156 g/mol
# sucrose C12H22O11 342.29648 g/mol
# ethanol C2H5OH 46.07 g/mol
# water H2O 18.01528 g/mol
# carbon dioxide CO2 44.01 g/mol
#: Weight ratio from byo.com article, for corn sugar: C6H12O6 -> 2 C2H5OH + 2 CO2
#: where the sugar is dextrose monohydrate C6H12O6·H2O
dextrose_co2_weight_conversion = (180.156 + 18.01528) / (2 * 44.01)
#: Weight ratio for cane or beet sugar: C12H22O11 + H2O -> 4 C2H5OH + 4 CO2
sucrose_co2_weight_conversion = 342.29648 / (4 * 44.01)
#: Reference data per beer style from byo.com article, as STP Volumes of CO2 per unit volume of beer
co2_vol_per_styles = '''
American ales 2.2 3.0
British ales 1.5 2.2
German weizens 2.8 5.1
Belgian ales 2.0 4.5
European Lagers 2.4 2.6
American Lagers 2.5 2.8
'''
def ref_styles():
""" Return guidelines of dissolved CO2 per beer style, from the byo.com article
Returns:
`pd.DataFrame`: high and low bounds (in columns) per beer style (along index) of dissolved CO2, in g/L
"""
df = pd.read_fwf(io.StringIO(co2_vol_per_styles), header=None)
df.columns = ['style', 'low', 'high']
return df.set_index('style').sort_values(['low', 'high']).mul(co2_g_per_stp_vol)
def dissolved_co2(temperature, pressure=1.01325):
""" Compute the amount of CO2 dissolved in beer, according to temperature and pressure
Args:
temperature (`float`): Temperature of beer, in °C
pressure (`float`): Pressure of headspace in fermenter or bottle, in bar
Returns:
`float`: dissolved CO2 in g / L
"""
return co2_g_per_stp_vol * (pressure * (0.264113616718 + 1.30700706043 * np.exp(-temperature / 23.95)) - 0.003342)
def titleprint(string):
""" Simple utility to pretty-print titles before dataframes """
print()
print(string)
print('-' * len(string))
@click.command(context_settings=dict(help_option_names=['-h', '--help']))
@click.option('-V', '--volume', type=float,
help='Compute result for specific beer volume (in L)')
@click.option('-P', '--pressure', type=float, default=1.01325, show_default=True,
help='Atomspheric pressure in bar')
@click.option('-S/-D', '--sucrose/--dextrose', 'is_sucrose', default=True, show_default=True,
help='Select type of sugar: sucrose for cane or beet sugar, dextrose for corn sugar')
@click.option('-T', '--temperature', type=float, multiple=True,
help='Print values at temperature(s) (in °C)')
@click.option('-v', '--co2-vol', type=float, multiple=True,
help='Add specified amount of CO2 (in L at STP per L of beer)')
@click.option('-w', '--co2-weight', type=float, multiple=True,
help='Add specified amount of CO2 (in g per L of beer)')
@click.option('-s', '--sugar-temp', type=(float, float), multiple=True,
help='Add line for specified amount of sugar at temperature')
def plot(volume=None, pressure=1.01325, is_sucrose=True, temperature=[], co2_vol=[], co2_weight=[], sugar_temp=[]):
""" Compute and plot priming sugar for specified amount of CO2
Additionally compute and plot low-high bounds of reference beer styles,
and optionally print all values at specified temperatures. """
# Convert all units and perform computations as needed
co2_ref = ref_styles().stack()
co2_pre = pd.Index(np.arange(0, 20.5, .5)).to_series().apply(dissolved_co2, pressure=pressure)
ref_add = pd.DataFrame(co2_ref.values[np.newaxis,:] - co2_pre.values[:,np.newaxis],
index=co2_pre.index, columns=co2_ref.index)
sugar_co2_weight_conversion = sucrose_co2_weight_conversion if is_sucrose else dextrose_co2_weight_conversion
ref_add_sugar = ref_add.mul(sugar_co2_weight_conversion).mul(1 if volume is None else volume)
sugar_unit = f'g per {volume}L batch' if volume else 'g/L'
add_lines = pd.Series({
**{f'CO2 {vol} L (STP)/L': vol * co2_g_per_stp_vol for vol in co2_vol},
**{f'CO2 {weight} g/L': weight for weight in co2_weight},
**{f'{sugar} g/L sugar at {temp}°C': sugar / sugar_co2_weight_conversion + dissolved_co2(temp, pressure=pressure)
for sugar, temp in sugar_temp},
}, dtype=float)
# Do some prints, especially if temperatures have been specified
titleprint(f'Unit conversion between dissolved CO2 quantities')
print_conv = pd.concat(axis='columns', names=['unit'], objs={
f'g/L': add_lines,
f'L (STP)/L': add_lines.div(co2_g_per_stp_vol),
})
print(print_conv)
if temperature:
print_co2_pre = (
pd.Index(temperature, dtype=float, name='temperature')
.to_series()
.apply(dissolved_co2, pressure=pressure)
.rename(index=lambda t: f'{t}°C')
)
print_co2 = pd.concat(axis='columns', names=['unit'], objs={
f'g/L': print_co2_pre,
f'L (STP)/L': print_co2_pre.div(co2_g_per_stp_vol),
})
titleprint(f'Dissolved CO2 at {pressure} bar')
print(print_co2)
print_add = pd.DataFrame(add_lines.values[np.newaxis,:] - print_co2_pre.values[:,np.newaxis],
index=print_co2_pre.index, columns=add_lines.index)
print_add = print_add.mul(sugar_co2_weight_conversion).mul(1 if volume is None else volume)
titleprint(f'Priming sugars in {sugar_unit}')
print(print_add)
print_ref_add = pd.DataFrame(co2_ref.values[np.newaxis,:] - print_co2_pre.values[:,np.newaxis],
index=print_co2_pre.index, columns=co2_ref.index)
print_ref_add = print_ref_add.mul(sugar_co2_weight_conversion).mul(1 if volume is None else volume)
titleprint(f'Priming sugars (in {sugar_unit}) for reference styles at specified temperatures')
print(print_ref_add)
# Plot data
fig, ax = plt.subplots(figsize=(10, 6))
ax.set_title(f'Priming sugar depending on beer temperature and style', loc='left')
ax.text(1, 1, f' at {pressure} bar', va='top', ha='left', transform=ax.transAxes)
for beer_style, ls, color in zip(
ref_add_sugar.columns.levels[0],
itertools.cycle(['-', '--', '-.', ':']),
['#7fc97f', '#beaed4', '#fdc086', '#ffff99', '#386cb0', '#f0027f'],
):
ax.fill_between(ref_add_sugar.index, ref_add_sugar[beer_style]['high'], ref_add_sugar[beer_style]['low'],
label=beer_style, color=f'{color}80', ec=color, ls=ls, lw=2)
for (label, weight), ls in zip(add_lines.items(), itertools.cycle(['-', '--', '-.', ':'])):
add_sugar = (weight - co2_pre).mul(sugar_co2_weight_conversion).mul(1 if volume is None else volume)
ax.plot(add_sugar.index, add_sugar.values, color='k', lw=2, ls=ls, label=label)
ax.set_xlim(0, 20)
ax.set_xlabel('Temperature (°C)')
ax.axhline(0, color='k', lw=1)
ax.set_ylabel(f'Priming sugar ({sugar_unit})')
hnd, lbl = ax.get_legend_handles_labels()
ax.legend(hnd[::-1], lbl[::-1], title='Beer style', loc='center left', bbox_to_anchor=(1, .5))
fig.tight_layout()
plt.show()
if __name__ == '__main__':
plot()
[project]
name = "priming_sugar_calculator"
version = "0.0.0"
description = "Calculate the amount of sugar to add when bottling your bear"
authors = [
{name = "Cimbali", email="me@cimba.li"},
]
license = {text = "MIT"}
requires-python = ">=3.6"
keywords = [
"Homebrewing",
"Bottling beer",
"Priming"
]
classifiers = [
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Framework :: Matplotlib",
"Topic :: Scientific/Engineering :: Chemistry",
"Topic :: Other/Nonlisted Topic"
]
dependencies = [
"Click>=8.0",
"numpy",
"pandas",
"matplotlib"
]
[project.scripts]
priming-sugar-calculator = "priming_sugar_calculator:plot"
[project.urls]
homepage = "https://gist.github.com/"
documentation = "https://github.com/MartinThoma/infer_pyproject"
repository = "https://github.com/MartinThoma/infer_pyproject"
[build-system]
requires = [
"setuptools >= 35.0.2",
"setuptools_scm >= 2"
]
build-backend = "setuptools.build_meta"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment