brewery-website/beer/models.py
Chris Giacofei 1d830eb22d More batch info.
Please enter the commit message for your changes. Lines starting
2024-06-24 16:25:18 -04:00

551 lines
17 KiB
Python

from django.db import models
from django.db.models import Sum, Q
from django.utils import timezone
from django_cryptography.fields import encrypt
from django.core.validators import MinValueValidator
from config.extras import BREWFATHER_APP_ROOT
from django.conf import settings
import logging
logger = logging.getLogger('django')
class CustomModel(models.Model):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
class Meta:
abstract = True
class UserProfile(CustomModel):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
brewfather_api_user = encrypt(models.TextField(max_length=128))
brewfather_api_key = encrypt(models.TextField(max_length=128))
def __str__(self):
return self.user.username
class Batch(CustomModel):
brewfather_id = models.CharField(max_length=50)
brewfather_num = models.IntegerField(default=1)
brewfather_name = models.CharField(max_length=500, default='name')
recipe = models.OneToOneField(
'Recipe', on_delete=models.CASCADE, default=1)
first_runnings = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True)
# Batch measurements to add:
# - Mash pH
# - First Runnings gravity (include lookup table fo rmash thickness)
# - Boil Vol
# - Pre-Boil Gravity
# - Post-Boil Vol
# - Post-Boil Gravity
# - Original Gravity
# - Final Gravity
# - Fermenter Top-Up
# - Fermenter Vol
# Properties Needed: (https://braukaiser.com/wiki/index.php/Troubleshooting_Brewhouse_Efficiency)
# - Conversion Efficiency
# - Mash Efficiency
# - Brewhouse Efficiency
# kettle extract weight in kg = volume in liter * SG * Plato / 100
# brewhouse efficiency in % = 100% * kettle extract weight in kg / extract in grist in kg
# - ABV
# - Attenuation
# - Actual Boil-Off Rate
# - Actual Trub/Chiller Loss
@property
def conversion_efficiency(self):
""" Calculate conversion efficiency of mash."""
strike_volume = (self.recipe.equipment.mash_ratio
* self.recipe.fermentable_weight)
pot_yield = 0
for ferm in self.recipefermentable_set.all():
pot_yield += ferm.extract_potential * ferm.quantity
pot_yield = pot_yield / self.recipe.fermentable_weight
fw_max = pot_yield / (strike_volume+pot_yield)
return (100 * (self.first_runnings/fw_max)
* (100-fw_max) / (100-self.first_runnings))
@property
def brewfather_url(self):
return '{}/tabs/batches/batch/{}'.format(
BREWFATHER_APP_ROOT,
self.brewfather_id
)
def __str__(self):
# Return a string that represents the instance
return 'BF #{num}: {name}'.format(
name=self.brewfather_name,
num=self.brewfather_num
)
# ----------------------------------------------------------------------
# Recipe Stuff
# ----------------------------------------------------------------------
class Unit(CustomModel):
unit_types = {
'WT': 'Weight',
'VL': 'Volume',
}
""" Recipe to be stored with a batch."""
name = models.CharField(max_length=50)
unit_type = models.CharField(
max_length=3, choices=unit_types, default='WT')
def __str__(self):
return self.name
class Supplier(CustomModel):
name = models.CharField(max_length=50)
def __str__(self):
return self.name
class CustomIngredient(CustomModel):
""" Custom model class with default fields to use. """
created_date = models.DateTimeField(default=timezone.now)
name = models.CharField(max_length=50)
units = models.ForeignKey(Unit, on_delete=models.PROTECT)
unit_cost = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True)
supplier = models.ForeignKey(
Supplier, on_delete=models.PROTECT, null=True, blank=True)
notes = models.TextField(max_length=500, blank=True, null=True)
user_notes = models.TextField(max_length=500, blank=True, null=True)
parent = models.ForeignKey(
'self', on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
abstract = True
class Recipe(CustomModel):
""" Recipe to be stored with a batch."""
name = models.CharField(max_length=50)
batch_recipe = models.BooleanField(null=True)
recipe_json = models.TextField(null=True, blank=True)
mash = models.ForeignKey(
'Mash', on_delete=models.PROTECT, null=True, blank=True)
equipment = models.ForeignKey(
'EquipmentProfile', on_delete=models.PROTECT, null=True, blank=True)
efficiency = models.DecimalField(
max_digits=6, decimal_places=2, default=75)
batch_size = models.DecimalField(
max_digits=6, decimal_places=2, default=11)
fermentables = models.ManyToManyField(
'Fermentable', through='RecipeFermentable')
class Meta:
verbose_name = 'Recipe'
verbose_name_plural = 'Recipes'
@property
def fermentable_weight(self):
"""Weight of all fermentables attached to recipe."""
aggregate = self.recipefermentable_set.all().aggregate(Sum('quantity'))
return aggregate['quantity__sum']
@property
def hop_weight(self):
"""Weight of all fermentables attached to recipe."""
aggregate = self.recipehop_set.all().aggregate(Sum('quantity'))
return aggregate['quantity__sum']
@property
def final_volume(self):
"""Return final volume (after boil)."""
return (float(self.batch_size)
+ self.hop_water_loss
+ self.net_kettle_deadspace
+ self.kettle_hose_loss)
@property
def sugar_yield(self):
"""Return point yield of all non-mashed ingredients."""
ferm_yield = 0
ferms = self.recipefermentable_set.all().select_related('fermentable')
sugars = ferms.filter(fermentable__fermentable_type=3)
for f in sugars:
ferm_yield += f.ferm_yield
return float(ferm_yield)
@property
def mash_yield(self):
"""Return point yield of all mashed ingredients."""
mash_yield = 0
ferms = self.recipefermentable_set.all().select_related('fermentable')
# Is not sugar (3)
mashed = ferms.filter(~Q(fermentable__fermentable_type=3))
for f in mashed:
mash_yield += f.ferm_yield
return float(mash_yield)
@property
def original_sg(self):
"""Return original gravity."""
total_yield = self.sugar_yield + self.mash_yield
gravity_points = total_yield/self.final_volume
return round(1 + gravity_points/1000, 3)
@property
def pre_boil_sg(self):
"""Return pre-boil gravity."""
total_yield = self.sugar_yield + self.mash_yield
total_water = self.final_volume+self.boil_off_gph
gravity_points = total_yield/total_water
return round(1 + gravity_points/1000, 3)
@property
def hop_water_loss(self):
return float(sum(
x.quantity*x.trub_volume for x in self.recipehop_set.all()
))
@property
def net_kettle_deadspace(self):
if self.equipment is None:
return 0
# If hops in kettle deadspace
# No deadspace if its all filled with hop trub
if self.equipment.hops_remain_kettle:
result = self.kettle_dead_space - self.hop_water_loss
return float(max(0, result))
else:
return 0
@property
def kettle_hose_loss(self):
if self.equipment is None:
return 0
return float(self.equipment.kettle_plumbing_loss)
@property
def kettle_dead_space(self):
if self.equipment is None:
return 0
return float(self.equipment.kettle_deadspace)
@property
def boil_off_gph(self):
if self.equipment is None:
return 0
return float(self.equipment.kettle_boil_rate)
@property
def ibu(self): # TODO: Multiple IBU formulas
return self.ibu_tinseth
@property
def ibu_tinseth(self):
return sum(x.ibu_tinseth for x in self.recipehop_set.all())
@property
def srm(self):
color_total = sum(x.lovibond_contributed
for x in self.recipefermentable_set.all())
return 1.4922*(color_total**0.6859)
@property
def srm_hex(self):
srm_hex_lookup = {
1: 'F3F993', 2: 'F5F75C', 3: 'F6F513', 4: 'EAE615',
5: 'E0D01B', 6: 'D5BC26', 7: 'CDAA37', 8: 'C1963C',
9: 'BE8C3A', 10: 'BE823A', 11: 'C17A37', 12: 'BF7138',
13: 'BC6733', 14: 'B26033', 15: 'A85839', 16: '985336',
17: '8D4C32', 18: '7C452D', 19: '6B3A1E', 20: '5D341A',
21: '4E2A0C', 22: '4A2727', 23: '361F1B', 24: '261716',
25: '231716', 26: '19100F', 27: '16100F', 28: '120D0C',
29: '100B0A', 30: '050B0A', 0: 'C1963C'
}
return '#{}'.format(srm_hex_lookup[int(self.srm)])
@property
def bu_gu(self):
gu = (self.original_sg - 1) * 1000
try:
return self.ibu_tinseth / gu
except ZeroDivisionError:
return 0
@property
def rbr(self): # .75 needs to be calculated number...
return self.bu_gu * (1 + (.75 - 0.7655))
def __str__(self):
return self.name
class Fermentable(CustomIngredient):
categories = {
1: 'Base',
2: 'Wheat/Oat',
3: 'Crystal',
4: 'Roasted',
5: 'Acid',
}
types = {
1: 'Grain',
2: 'Adjunct',
3: 'Sugar'
}
grain_category = models.IntegerField(
choices=categories, default=1)
fermentable_type = models.IntegerField(choices=types, default=1)
diastatic_power = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
potential = models.DecimalField(max_digits=6, decimal_places=4)
protein = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
attenuation = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
lovibond = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
max_in_batch = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
moisture = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True)
non_fermentable = models.BooleanField(null=True, blank=True)
ibu_per_unit = models.DecimalField(
max_digits=6, decimal_places=4, default=0)
def __str__(self):
return self.name
class RecipeFermentable(CustomModel):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
fermentable = models.ForeignKey(Fermentable, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4)
# Properties Needed:
# - Extract Potential (1-moisture percent as decimal) * potential
# - The weight of extract in the grist
# extract in grist in kg = weight of grist in kg * extract potential
@property
def extract_potential(self):
return .8 * .96
@property
def percent(self):
return float(100 * self.quantity / self.recipe.fermentable_weight)
@property
def lovibond_contributed(self):
srm_calc = (float(self.fermentable.lovibond)
* float(self.quantity) / self.recipe.final_volume)
return round(srm_calc, 1)
@property
def ferm_yield(self):
potential_yield = self.quantity * (self.fermentable.potential-1) * 1000
if self.fermentable.fermentable_type == 3:
return potential_yield
else:
return potential_yield * (self.recipe.efficiency/100)
class Hop(CustomIngredient):
uses = {
1: 'Bittering',
2: 'Aroma',
3: 'Both',
}
types = {
1: 'Pellet',
2: 'Leaf',
3: 'Cryo',
4: 'CO2 Extract',
}
ibu = models.DecimalField(max_digits=6, decimal_places=4, default=0)
use = models.IntegerField(choices=uses, default=1)
hop_type = models.IntegerField(choices=types, default=1)
alpha = models.DecimalField(max_digits=6, decimal_places=2, default=0)
def __str__(self):
return self.name
class RecipeHop(CustomModel):
uses = {
1: 'Boil',
2: 'Dry Hop',
3: 'Aroma (Hop Stand)',
4: 'Mash',
5: 'First Wort'
}
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
hop = models.ForeignKey(Hop, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4)
time = models.IntegerField(default=60, validators=[MinValueValidator(0)])
use = models.IntegerField(choices=uses, default=1)
@property
def ibu_tinseth(self):
type_bonus = {
1: 1.1, # Pellet
2: 1.0, # Leaf
3: 1.1, # Cryo
4: 1.4, # CO2 Extract
}
hop_bonus = type_bonus[self.hop.hop_type]
average_wort_sg = (((self.recipe.pre_boil_sg-1)
+ (self.recipe.original_sg-1)) / 2)
if self.use == 1:
conc = (float((self.hop.alpha/100) * self.quantity)
* 7490/self.recipe.final_volume)
util = (hop_bonus*1.65*0.000125**average_wort_sg
* ((1-2.71828182845904**(-0.04*self.time)) / 4.15))
ibu = conc * util
else:
ibu = 0
return float(ibu)
@property
def trub_volume(self):
if self.hop.hop_type == 1:
return self.recipe.equipment.pellet_hop_trub
elif self.hop.hop_type in [2, 3]:
return self.recipe.equipment.leaf_hop_trub
else:
return 0
class Misc(CustomIngredient):
uses = {
1: 'Mash',
2: 'Sparge',
3: 'Boil',
4: 'Flamout',
5: 'Primary',
6: 'Secondary',
7: 'Cold Crash',
8: 'Bottling',
}
types = {
1: 'Spice',
2: 'Fining',
3: 'Water Agent',
4: 'Herb',
5: 'Flavor',
6: 'Other',
}
use = models.IntegerField(choices=uses, default=1)
misc_type = models.IntegerField(choices=types, default=1)
water_adjustment = models.BooleanField(null=True, blank=True)
def __str__(self):
return self.name
class RecipeMisc(CustomModel):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
misc = models.ForeignKey(Misc, on_delete=models.CASCADE)
quantity = models.DecimalField(max_digits=6, decimal_places=4)
def __str__(self):
return self.name
class RecipeYeast(CustomModel):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
yeast = models.ForeignKey('yeast.Strain', on_delete=models.CASCADE)
class Mash(CustomModel):
name = models.CharField(max_length=50)
parent = models.ForeignKey(
'self', on_delete=models.SET_NULL, null=True, blank=True)
def __str__(self):
return self.name
class MashStep(CustomModel):
step_types = {
1: 'infusion',
2: 'temperature',
3: 'decoction',
}
name = models.CharField(max_length=50)
step_temp = models.DecimalField(max_digits=6, decimal_places=2)
ramp_time = models.DecimalField(max_digits=6, decimal_places=2)
step_time = models.DecimalField(max_digits=6, decimal_places=2)
step_type = models.IntegerField(choices=step_types, default=1)
parent_mash = models.ForeignKey(Mash, on_delete=models.CASCADE)
def __str__(self):
return self.name
class EquipmentProfile(CustomModel):
name = models.CharField(max_length=50)
parent = models.ForeignKey(
'self', on_delete=models.SET_NULL, null=True, blank=True)
# Water managment stuff
hlt_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25)
mt_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25)
mt_capacity = models.DecimalField(
max_digits=6, decimal_places=2, default=10)
grain_absorption = models.DecimalField(
max_digits=6, decimal_places=2, default=0.12) # gal/lb
kettle_deadspace = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25)
kettle_plumbing_loss = models.DecimalField(
max_digits=6, decimal_places=2, default=0.25)
kettle_boil_rate = models.DecimalField(
max_digits=6, decimal_places=2, default=0.5) # gal/hr
batch_volume = models.DecimalField(
max_digits=6, decimal_places=2, default=5.5)
leaf_hop_trub = models.DecimalField(
max_digits=6, decimal_places=4, default=0.0625)
pellet_hop_trub = models.DecimalField(
max_digits=6, decimal_places=4, default=0.025)
hops_remain_kettle = models.BooleanField(default=True)
mash_ratio = models.DecimalField(
max_digits=6, decimal_places=2, default=2.6) # 1.25 qt/lb
# Thermal Properties
mt_initial_hear = models.DecimalField(
max_digits=6, decimal_places=4, default=0.74)
mt_heat_loss_hour = models.DecimalField(
max_digits=6, decimal_places=4, default=2.0)