Last active
October 20, 2022 08:32
-
-
Save Cimbali/7bd5ad980bd0113f04276d726f2022e7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 <[email protected]>' | |
__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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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="[email protected]"}, | |
] | |
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