From 90bf91c3ed05602625755c14b61269475984ccbe Mon Sep 17 00:00:00 2001 From: Chris Giacofei Date: Wed, 26 Jun 2024 11:48:34 -0400 Subject: [PATCH] Added additional database fields and properties. Much calculation of efficiency and stuff now. --- beer/admin.py | 34 +++- ...st_boil_sg_batch_post_boil_vol_and_more.py | 33 ++++ ..._topup_vol_batch_fermenter_vol_and_more.py | 53 ++++++ beer/models.py | 169 ++++++++++++------ beer/templates/beer/recipe.html | 10 +- 5 files changed, 235 insertions(+), 64 deletions(-) create mode 100644 beer/migrations/0014_batch_post_boil_sg_batch_post_boil_vol_and_more.py create mode 100644 beer/migrations/0015_batch_fermenter_topup_vol_batch_fermenter_vol_and_more.py diff --git a/beer/admin.py b/beer/admin.py index 336255d..86d8d95 100644 --- a/beer/admin.py +++ b/beer/admin.py @@ -7,6 +7,7 @@ from beer.models import Batch, Recipe, Mash, MashStep, \ from yeast.models import Yeast from config.extras import BREWFATHER_APP_ROOT +from beer.extras import plato_sg class SampleInline(admin.TabularInline): @@ -36,7 +37,7 @@ class StrainInline(admin.TabularInline): @admin.register(Recipe) class RecipeAdmin(admin.ModelAdmin): - list_display = ['name'] + list_display = ['name', 'total_extract_kg'] inlines = [ FermentableInline, HopInline, @@ -47,11 +48,40 @@ class RecipeAdmin(admin.ModelAdmin): @admin.register(Batch) class BeerBatchAdmin(admin.ModelAdmin): - list_display = ['brewfather_id', 'batch_url'] + list_display = [ + 'brewfather_id', + 'batch_url', + 'brewhouse_efficiency', + 'conversion_efficiency', + ] inlines = [ SampleInline, ] + fieldsets = [ + [None, { + 'fields': [ + ('brewfather_id', 'brewfather_num', 'brewfather_name'), + 'recipe', + ] + }], + ['Mash', { + 'fields': ['first_runnings', 'mash_ph'], + }], + ['Boil', { + 'fields': [ + ('pre_boil_vol', 'pre_boil_sg'), + ('post_boil_vol', 'post_boil_sg'), + ], + }], + ['Ferment', { + 'fields': [ + ('fermenter_topup_vol', 'fermenter_vol'), + ('original_sg', 'final_sg'), + ], + }], + ] + def batch_url(self, obj): url_string = ('' 'Brewfather Batch ID: {batch_id}') diff --git a/beer/migrations/0014_batch_post_boil_sg_batch_post_boil_vol_and_more.py b/beer/migrations/0014_batch_post_boil_sg_batch_post_boil_vol_and_more.py new file mode 100644 index 0000000..a04becb --- /dev/null +++ b/beer/migrations/0014_batch_post_boil_sg_batch_post_boil_vol_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2024-06-25 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('beer', '0013_batch_first_runnings_equipmentprofile_mash_ratio'), + ] + + operations = [ + migrations.AddField( + model_name='batch', + name='post_boil_sg', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=6, null=True), + ), + migrations.AddField( + model_name='batch', + name='post_boil_vol', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True), + ), + migrations.AlterField( + model_name='fermentable', + name='moisture', + field=models.DecimalField(blank=True, decimal_places=4, default=0.04, max_digits=6, null=True), + ), + migrations.AlterField( + model_name='fermentable', + name='potential', + field=models.DecimalField(decimal_places=4, default=0.8, max_digits=6), + ), + ] diff --git a/beer/migrations/0015_batch_fermenter_topup_vol_batch_fermenter_vol_and_more.py b/beer/migrations/0015_batch_fermenter_topup_vol_batch_fermenter_vol_and_more.py new file mode 100644 index 0000000..31e7d31 --- /dev/null +++ b/beer/migrations/0015_batch_fermenter_topup_vol_batch_fermenter_vol_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.6 on 2024-06-26 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('beer', '0014_batch_post_boil_sg_batch_post_boil_vol_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='batch', + name='fermenter_topup_vol', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True), + ), + migrations.AddField( + model_name='batch', + name='fermenter_vol', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True), + ), + migrations.AddField( + model_name='batch', + name='final_sg', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), + ), + migrations.AddField( + model_name='batch', + name='mash_ph', + field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True), + ), + migrations.AddField( + model_name='batch', + name='original_sg', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), + ), + migrations.AddField( + model_name='batch', + name='pre_boil_sg', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), + ), + migrations.AddField( + model_name='batch', + name='pre_boil_vol', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=8, null=True), + ), + migrations.AlterField( + model_name='batch', + name='post_boil_sg', + field=models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True), + ), + ] diff --git a/beer/models.py b/beer/models.py index 69f2236..7ccf7ed 100644 --- a/beer/models.py +++ b/beer/models.py @@ -5,6 +5,7 @@ from django_cryptography.fields import encrypt from django.core.validators import MinValueValidator from config.extras import BREWFATHER_APP_ROOT +from beer.extras import sg_plato, plato_sg, kg_extract, convert from django.conf import settings import logging @@ -36,47 +37,70 @@ class Batch(CustomModel): recipe = models.OneToOneField( 'Recipe', on_delete=models.CASCADE, default=1) + # ------------------------------------------------------------------------- + # Brewday Measurements + # ------------------------------------------------------------------------- + + ## Mash first_runnings = models.DecimalField( max_digits=8, decimal_places=4, null=True, blank=True) + mash_ph = models.DecimalField( + max_digits=4, decimal_places=3, 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 + ## Boil + pre_boil_vol = models.DecimalField( + max_digits=8, decimal_places=4, null=True, blank=True) + pre_boil_sg = models.DecimalField( + max_digits=5, decimal_places=4, null=True, blank=True) + post_boil_vol = models.DecimalField( + max_digits=8, decimal_places=4, null=True, blank=True) + post_boil_sg = models.DecimalField( + max_digits=5, decimal_places=4, null=True, blank=True) + + ## Ferment + fermenter_topup_vol = models.DecimalField( + max_digits=8, decimal_places=4, null=True, blank=True) + fermenter_vol = models.DecimalField( + max_digits=8, decimal_places=4, null=True, blank=True) + original_sg = models.DecimalField( + max_digits=5, decimal_places=4, null=True, blank=True) + final_sg = models.DecimalField( + max_digits=5, decimal_places=4, null=True, blank=True) # 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 + # ------------------------------------------------------------------------- + # Batch Stats + # ------------------------------------------------------------------------- + @property + def brewhouse_efficiency(self): + try: + return round(self.boil_extract_kg/self.recipe.total_extract_kg,4) + except ZeroDivisionError: + return 0 + + @property + def boil_extract_kg(self): + return kg_extract(float(self.post_boil_vol)*.96, self.post_boil_sg) + @property def conversion_efficiency(self): """ Calculate conversion efficiency of mash.""" - strike_volume = (self.recipe.equipment.mash_ratio - * self.recipe.fermentable_weight) + if self.first_runnings is None or self.recipe.fermentable_weight_kg == 0: + return '-' - pot_yield = 0 - for ferm in self.recipefermentable_set.all(): - pot_yield += ferm.extract_potential * ferm.quantity + return round((sg_plato(self.first_runnings)/self.recipe.fw_max) + * (100-self.recipe.fw_max) / (100-sg_plato(self.first_runnings)) + , 4) - 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 boil_off_calcualted(self): + return float(self.pre_boil_vol - self.post_boil_vol) * .96 @property def brewfather_url(self): @@ -157,16 +181,41 @@ class Recipe(CustomModel): verbose_name_plural = 'Recipes' @property - def fermentable_weight(self): + def fw_max(self): + potential = 0 + weight = 0 + for ferm in self.recipefermentable_set.all(): + potential += ferm.fermentable.extract_percent*float(ferm.quantity) + weight += float(ferm.quantity) + + e_grain = potential / weight + + ratio = float(self.equipment.mash_ratio) + return 100*e_grain / (ratio+e_grain) + + @property + def fermentable_weight_kg(self): """Weight of all fermentables attached to recipe.""" aggregate = self.recipefermentable_set.all().aggregate(Sum('quantity')) - return aggregate['quantity__sum'] + + if aggregate['quantity__sum']: + return aggregate['quantity__sum'] + else: + return 0 + + @property + def total_extract_kg(self): + extract = 0 + for f in self.recipefermentable_set.all(): + extract += f.extract_weight_kg + + return extract @property def hop_weight(self): """Weight of all fermentables attached to recipe.""" aggregate = self.recipehop_set.all().aggregate(Sum('quantity')) - return aggregate['quantity__sum'] + return float(aggregate['quantity__sum']) * 0.0352739619 @property def final_volume(self): @@ -179,44 +228,43 @@ class Recipe(CustomModel): @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) + ferm_yield = 0 for f in sugars: - ferm_yield += f.ferm_yield + ferm_yield += f.extract_weight_kg 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)) + mash_yield = 0 for f in mashed: - mash_yield += f.ferm_yield + mash_yield += f.extract_weight_kg * float(self.efficiency/100) 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) + total_extract = self.sugar_yield + self.mash_yield + plato = 100 * total_extract / (self.final_volume + total_extract) + return round(plato_sg(plato), 3) @property def pre_boil_sg(self): """Return pre-boil gravity.""" - total_yield = self.sugar_yield + self.mash_yield + total_extract = 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) + plato = 100 * total_extract / (total_water + total_extract) + return round(plato_sg(plato), 3) @property def hop_water_loss(self): @@ -320,7 +368,8 @@ class Fermentable(CustomIngredient): 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) + potential = models.DecimalField( + max_digits=6, decimal_places=4, default=0.80) protein = models.DecimalField( max_digits=6, decimal_places=4, null=True, blank=True) attenuation = models.DecimalField( @@ -330,11 +379,15 @@ class Fermentable(CustomIngredient): 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) + max_digits=6, decimal_places=4, null=True, blank=True, default=0.04) non_fermentable = models.BooleanField(null=True, blank=True) ibu_per_unit = models.DecimalField( max_digits=6, decimal_places=4, default=0) + @property + def extract_percent(self): + return ((float(self.potential)-1)*1000/46.17) * (1-float(self.moisture)/100) + def __str__(self): return self.name @@ -345,32 +398,29 @@ class RecipeFermentable(CustomModel): 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 + def quantity_display(self): + return convert(self.quantity, 'kg', 'lb') + + @property + def extract_weight_kg(self): + return float(self.quantity) * self.fermentable.extract_percent @property def percent(self): - return float(100 * self.quantity / self.recipe.fermentable_weight) + return float(100 * self.quantity / self.recipe.fermentable_weight_kg) @property def lovibond_contributed(self): srm_calc = (float(self.fermentable.lovibond) - * float(self.quantity) / self.recipe.final_volume) + * convert(self.quantity, 'kg', 'lb') + / convert(self.recipe.final_volume, 'l', 'gal')) + 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 = { @@ -409,6 +459,11 @@ class RecipeHop(CustomModel): time = models.IntegerField(default=60, validators=[MinValueValidator(0)]) use = models.IntegerField(choices=uses, default=1) + @property + def quantity_display(self): + """Convert grams to ounces.""" + return convert(self.quantity, 'g', 'oz') + @property def ibu_tinseth(self): type_bonus = { @@ -424,8 +479,8 @@ class RecipeHop(CustomModel): + (self.recipe.original_sg-1)) / 2) if self.use == 1: - conc = (float((self.hop.alpha/100) * self.quantity) - * 7490/self.recipe.final_volume) + conc = (float((self.hop.alpha/100)) * float(self.quantity)*0.0352739619 + * 7490/convert(self.recipe.final_volume, 'l', 'gal')) util = (hop_bonus*1.65*0.000125**average_wort_sg * ((1-2.71828182845904**(-0.04*self.time)) / 4.15)) ibu = conc * util diff --git a/beer/templates/beer/recipe.html b/beer/templates/beer/recipe.html index c90a1a0..ea4a851 100644 --- a/beer/templates/beer/recipe.html +++ b/beer/templates/beer/recipe.html @@ -88,8 +88,8 @@ font-size: .8em;

- Pre-Boil Gravity: {{ recipe.pre_boil_sg }}
- Original Gravity: {{ recipe.original_sg }}
+ Pre-Boil Gravity: {{ recipe.pre_boil_sg|floatformat:3 }}
+ Original Gravity: {{ recipe.original_sg|floatformat:3 }}
Color: {{ recipe.srm|floatformat:0 }} SRM

@@ -97,9 +97,9 @@ font-size: .8em; {% for f in recipe.recipefermentable_set.all %} - {{ f.quantity|floatformat:2 }} lb + {{ f.quantity_display|floatformat:2 }} lb {{ f.fermentable.name }}
- {{ f.fermentable.get_fermentable_type_display }} {{ f.srm }} SRM + {{ f.fermentable.get_fermentable_type_display }} {{ f.lovibond_contributed }} L {{ f.percent|floatformat:1 }} % @@ -132,7 +132,7 @@ font-size: .8em; {% for h in recipe.recipehop_set.all %} - {{ h.quantity|floatformat:2 }} oz + {{ h.quantity_display|floatformat:2 }} oz {{ h.hop.name }} {{ h.hop.alpha|floatformat:1 }} %
{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU