Skip to content

Commit

Permalink
Mash: allow multiple heating styles in the same mash.
Browse files Browse the repository at this point in the history
In other words, can now infuse, heat and decoct in the same recipe.

The main user-visible change is that mlt_heat=direct no longer
implies direct-fired mashes, the default is infusion.  (I will
rename the mlt_heat sysparam soon, probably to mashtun_striketemp)

I want to change the mashparams syntax eventually, but for now
the magic separator is ";", i.e. a step is defined as:

	time @ temperature ; method

time and method are optional, with time defaulting to undefined,
and method to infusion as mentioned above

Replace the infusion,step test logo with a mish[mash],step lego.
The latter includes all of strike, decoction, infusion and direct fire.
  • Loading branch information
anttikantee committed Jul 26, 2022
1 parent bb953a3 commit 982f908
Show file tree
Hide file tree
Showing 15 changed files with 180 additions and 83 deletions.
166 changes: 111 additions & 55 deletions WBC/mash.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@
class MashStep:
TIME_UNSPEC= object()

def __init__(self, temperature, time = TIME_UNSPEC):
INFUSION= 'infusion'
HEAT= 'heat'
DECOCTION= 'decoction'

valid_methods= [ INFUSION, HEAT, DECOCTION ]

def __init__(self, temperature, time = TIME_UNSPEC, method = None):
checktype(temperature, Temperature)
if time is not self.TIME_UNSPEC:
checktype(time, Duration)

self.temperature = temperature
self.time = time
self.method = method

def __str__(self):
rv = ''
Expand All @@ -39,9 +46,6 @@ def __str__(self):
return rv + str(self.temperature)

class Mash:
INFUSION= 'infusion'
DECOCTION= 'decoction'

# mash state and advancement calculator.
#
# In the context of this class, we use the following terminology:
Expand Down Expand Up @@ -161,7 +165,6 @@ def strike(self, target_temp, water_capa):
# calculate how much water we need to add (or remove)
# to get the system up to the new target temperature
def infusion1(self, target_temp, water_temp):
assert(getparam('mlt_heat') == 'transfer')
_c = self._capa
_h = self._heat

Expand Down Expand Up @@ -209,60 +212,94 @@ def decoction(self, target_temp, evap):
self._setvalues(-evap, target_temp)
return (_Mass(ds*dm), _Mass((1-ds)*dm))

# set the mash temperature to the given value
# without otherwise affecting the state (= direct fire)
def heat(self, target_temp):
self._setvalues(0, target_temp)

def __init__(self):
self.didmash = False

self.fermentables = []
self.giant_steps = None
self.method = self.INFUSION # default to infusion

# keep this as None until we know it's not going to
# be user-set (we know that in do_mash()).
self.defaultmethod = None

# mashing returns a dict which contains:
# * mashstep_water
# * sparge_water
# * [steps]:
def do_mash(self, ambient_temp, water, grains_absorb):
assert(self.method is Mash.INFUSION
or self.method is Mash.DECOCTION)

if self.giant_steps is None:
raise PilotError('trying to mash without temperature')
steps = self.giant_steps
assert(len(steps) >= 1)

# if the recipe did not specify a mash method,
# default to infusion for all steps
if not self.defaultmethod:
self.defaultmethod = MashStep.INFUSION
self._steps_setdefaults()

if len(self.fermentables) == 0:
raise PilotError('trying to mash without fermentables')
fmass = _Mass(sum(x.get_amount() for x in self.fermentables))
grainvol = self.__grainvol(fmass)

# Calculate the amount of water required to reach
# "totemp" when starting with "fromwater" and "fromtemp".
# Notably, the calculation may also be performed backwards.
def origwater(fromtemp, totemp, fromwater):
assert((fromtemp == steps[0].temperature or
fromtemp == steps[-1].temperature) and
(totemp == steps[0].temperature or
totemp == steps[-1].temperature))

evap = self.evaporation()
if getparam('mlt_heat') == 'direct':
return fromwater
if self.method is self.DECOCTION:
if fromtemp > totemp: evap = -evap
return fromwater - evap.water()

ms = self.__MashState(fmass,
_Temperature(20), _Temperature(20))
ms.strike(fromtemp, fromwater)
rv = ms.infusion(totemp)
return rv + fromwater
# Calculate the amount of water, going either forwards
# or backwards. See usage examples below to understand
# why it's needed. (forwards means start from mashin
# and calculate how much water we have at the end when
# all additions are losses are accounted for. backwards
# means starting from the end, and figuring out how
# much strike water we need)
#
# XXX: this routine is very similar to do_steps(). Could
# they be merged with reasonably ?
MASHDIR_BACK = -1
MASHDIR_FWD = 1
def water_otherend(dir, startwater):
# To get a mashstate, first do the strike,
# either using the initial or final temperature.
fsnum = min(0, dir)
fs = steps[fsnum]
ms = self.__MashState(fmass, ambient_temp, ambient_temp)
ms.strike(fs.temperature, startwater)

# Then actually calculate the amount of water at
# the other end.
#
# If we are going forwards, we go from steps
# 1 to the last. If we're going backwards,
# we go from the penultimate step (-2) to the
# first. Also, if we're going backwards, we
# need to use the method of the *previous*
# step.
method = fs.method
for s in steps[fsnum+dir::dir]:
if dir > 0: method = s.method
if method == s.HEAT:
ms.heat(s.temperature)
elif method == s.INFUSION:
mw = ms.infusion(s.temperature)
startwater -= dir * mw
elif method == s.DECOCTION:
evap = self.__decoction_evaporation(s)
evap *= dir
ms.decoction(s.temperature, evap)
startwater -= evap
else:
assert(False)
if dir < 0: method = s.method
return startwater

mashin_ratio = getparam('mashin_ratio')
if mashin_ratio[0] == '%':
absorb = fmass * grains_absorb
wmass_end = (mashin_ratio[1] / 100.0) \
* (water.water() + absorb)
wmass = origwater(steps[-1].temperature,
steps[0].temperature, wmass_end)
wmass = water_otherend(MASHDIR_BACK, wmass_end)
else:
assert(mashin_ratio[0] == '/')
rat = mashin_ratio[1][0] * mashin_ratio[1][1]
Expand All @@ -274,26 +311,26 @@ def origwater(fromtemp, totemp, fromwater):
if mwatermin > water.water():
mwatermin = water.water()
if wmass < mwatermin:
wmass = origwater(steps[-1].temperature,
steps[0].temperature, mwatermin)
wmass = water_otherend(MASHDIR_BACK, mwatermin)

# if necessary, adjust final mash volume to limit,
# or error if we can't
#
# XXX: we should use the *largest* volume, not final volume;
# largest volume may be in the middle due to evaporation
#
mvolmax = getparam('mashvol_max')
wmass_end = origwater(steps[0].temperature,
steps[-1].temperature, wmass)
wmass_end = water_otherend(MASHDIR_FWD, wmass)
mvol = grainvol + wmass_end
if mvolmax is not None and mvol > mvolmax:
veryminvol = grainvol + getparam('mlt_loss')
if mvolmax <= veryminvol+.1:
raise PilotError('cannot satisfy maximum '
'mash volume. adjust param or recipe')
wendmax = mvolmax - grainvol
wmass = origwater(steps[-1].temperature,
steps[0].temperature, wendmax)
wmass = water_otherend(MASHDIR_BACK, wendmax)

wmass_end = origwater(steps[0].temperature,
steps[-1].temperature, wmass)
wmass_end = water_otherend(MASHDIR_FWD, wmass)

# finally, if necessary adjust the lauter volume
# or error if either mash or lauter volume is beyond limit
Expand All @@ -319,18 +356,18 @@ def origwater(fromtemp, totemp, fromwater):
res['steps'] = stepres
res['mashstep_water'] = Worter(water = mashwater)
res['sparge_water'] = Worter(water = w - mashwater)
res['method'] = self.method

self.didmash = True
return res

def _do_steps(self, infusion_wmass, fmass, water_available,
ambient_temp):
def _decoction(step):
return self.method is Mash.DECOCTION
return step.method == MashStep.DECOCTION
def _infusion(step):
return (self.method is Mash.INFUSION
and getparam('mlt_heat') == 'transfer')
return step.method == MashStep.INFUSION
def _heat(step):
return step.method == MashStep.HEAT

stepres = []

Expand Down Expand Up @@ -371,16 +408,15 @@ def _infusion(step):
break

step_temp = s.temperature
infusion_wmass = _Mass(0)
if _infusion(s):
infusion_wmass = mashstate.infusion(step_temp)
infusion_wtemp = _Temperature(100)
water_available -= infusion_wmass
inmash.adjust_water(infusion_wmass)
else:
# direct-fire or decoction
infusion_wmass = _Mass(0)

if _decoction(s):
elif _heat(s):
mashstate.heat(step_temp)
elif _decoction(s):
evap = self.__decoction_evaporation(s)
gm, wm = mashstate.decoction(step_temp, evap)
inmash.adjust_water(-evap)
Expand All @@ -403,9 +439,11 @@ def __decoction_evaporation(self, step):

def evaporation(self):
m = 0
if self.method is Mash.DECOCTION:
if self.giant_steps is not None:
evasteps = [s for s in self.giant_steps[1:]
if s.method == s.DECOCTION]
m = sum([self.__decoction_evaporation(s)
for s in self.giant_steps[1:]])
for s in evasteps])
return Worter(water = _Mass(m))

def printcsv(self):
Expand All @@ -420,6 +458,12 @@ def printcsv(self):
def set_fermentables(self, fermentables):
self.fermentables = fermentables

def _steps_setdefaults(self):
if self.defaultmethod:
for s in self.giant_steps[1:]:
if s.method is None:
s.method = self.defaultmethod

def set_steps(self, mashsteps):
if isinstance(mashsteps, MashStep):
mashsteps = [mashsteps]
Expand All @@ -438,9 +482,21 @@ def set_steps(self, mashsteps):
raise PilotError('mash steps must be given as ' \
'MashStep or list of')

if len(mashsteps) == 0:
raise PilotError('mash needs at least one temperature')

if (mashsteps[0].method is not None
and mashsteps[0].method != MashStep.INFUSION):
raise PilotError('first mash step must be infusion')

# strike is always infusion
mashsteps[0].method = MashStep.INFUSION
self._steps_setdefaults()

self.giant_steps = mashsteps

def set_method(self, m):
if m is not Mash.INFUSION and m is not Mash.DECOCTION:
def set_defaultmethod(self, m):
if m not in MashStep.valid_methods:
raise PilotError('unsupported mash method')
self.method = m
self.defaultmethod = m
self._steps_setdefaults()
9 changes: 3 additions & 6 deletions WBC/output_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,12 @@ def _printmash(input, results):
water = x['water']
temp = x['temp']

# handle direct-heated mashtuns.
# XXX: should probably be more rigorously structured
# in the computation so that we don't need so much
# logic here on the "dumb" output side
if results['mash']['method'] == 'decoction' and i != 0:
if ms.method == ms.DECOCTION:
addition = 'decoct ' + str(x['decoction'])
elif getparam('mlt_heat') == 'direct' and i != 0:
elif ms.method == ms.HEAT:
addition = 'heat'
else:
assert(ms.method == ms.INFUSION)
addition = '{:>8}'.format(str(water.volume(temp)) \
+ ' @ {:>7}'.format(str(temp)))

Expand Down
26 changes: 16 additions & 10 deletions WBC/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ def split(input, splitter, i1, i2):
if len(marr) != 2:
raise ValueError('input must contain exactly one "' + splitter
+ '", you gave: ' + istr)
res1 = i1(marr[0])
res2 = i2(marr[1])
return (res1, res2)

res1 = None if i1 is None else i1(marr[0].strip())
res2 = None if i2 is None else i2(marr[1].strip())
return res1, res2

def ratio(input, r1, r2):
return twotuple(input, r1, r2, '/')
Expand All @@ -223,19 +224,24 @@ def timedtemperature(input):

def mashmethod(input):
methods = {
'infusion' : mash.Mash.INFUSION,
'decoction' : mash.Mash.DECOCTION,
'infusion' : mash.MashStep.INFUSION,
'heat' : mash.MashStep.HEAT,
'decoction' : mash.MashStep.DECOCTION,
}
if input in methods:
return methods[input]
raise PilotError('unsupported mash method: ' + str(input))
if input not in mash.MashStep.valid_methods:
raise PilotError('unsupported mash method: ' + str(input))
return input

def mashstep(input):
method = None
if ';' in input:
input, method = twotuple(input, str, mashmethod, ';')

if '@' in input:
r = timedtemperature(input)
return mash.MashStep(r[1], r[0])
return mash.MashStep(r[1], r[0], method = method)
else:
return mash.MashStep(temperature(input))
return mash.MashStep(temperature(input), method = method)

def _additionunit(input, acceptable):
ts = [x for x in acceptable if isinstance(x, tuple)]
Expand Down
2 changes: 1 addition & 1 deletion bin/wbcrecipe
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def domashparams(r, mashparams):

if p == 'method':
m = parse.mashmethod(value)
r.mash.set_method(m)
r.mash.set_defaultmethod(m)

elif p == 'temperature' or p == 'temperatures':
if isinstance(value, str):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ yeast: yeastieboys
volume: 20l
boil: 60min
mashparams:
temperature: [ 20min @ 65degC, 70degC, 15min @ 75degC ]
temperature:
- 10min @ 45degC
- 20min @ 62degC ; decoction
- 68degC ; heat
# infusion or heat, depending on method override
- 75degC
fermentables:
mash:
Avangard Pilsner: 3kg
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ yeast: yeastieboys
volume: 20l
boil: 60min
mashparams:
temperature: [ 20min @ 65degC, 70degC, 15min @ 75degC ]
temperature:
- 10min @ 45degC
- 20min @ 62degC ; decoction
- 68degC ; heat
# infusion or heat, depending on method override
- 75degC
fermentables:
mash:
Avangard Pilsner: 3kg
Expand Down
Loading

0 comments on commit 982f908

Please sign in to comment.