495 lines
15 KiB
Python
495 lines
15 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)
|
|
|
|
@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_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)
|
|
|
|
@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)
|
|
|
|
# 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)
|