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)