Added additional database fields and properties.

Much calculation of efficiency and stuff now.
This commit is contained in:
Chris Giacofei 2024-06-26 11:48:34 -04:00
parent 570ede627f
commit 90bf91c3ed
5 changed files with 235 additions and 64 deletions

View File

@ -7,6 +7,7 @@ from beer.models import Batch, Recipe, Mash, MashStep, \
from yeast.models import Yeast from yeast.models import Yeast
from config.extras import BREWFATHER_APP_ROOT from config.extras import BREWFATHER_APP_ROOT
from beer.extras import plato_sg
class SampleInline(admin.TabularInline): class SampleInline(admin.TabularInline):
@ -36,7 +37,7 @@ class StrainInline(admin.TabularInline):
@admin.register(Recipe) @admin.register(Recipe)
class RecipeAdmin(admin.ModelAdmin): class RecipeAdmin(admin.ModelAdmin):
list_display = ['name'] list_display = ['name', 'total_extract_kg']
inlines = [ inlines = [
FermentableInline, FermentableInline,
HopInline, HopInline,
@ -47,11 +48,40 @@ class RecipeAdmin(admin.ModelAdmin):
@admin.register(Batch) @admin.register(Batch)
class BeerBatchAdmin(admin.ModelAdmin): class BeerBatchAdmin(admin.ModelAdmin):
list_display = ['brewfather_id', 'batch_url'] list_display = [
'brewfather_id',
'batch_url',
'brewhouse_efficiency',
'conversion_efficiency',
]
inlines = [ inlines = [
SampleInline, 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): def batch_url(self, obj):
url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">' url_string = ('<a href="{root}/tabs/batches/batch/{batch_id}">'
'Brewfather Batch ID: {batch_id}</a>') 'Brewfather Batch ID: {batch_id}</a>')

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -5,6 +5,7 @@ from django_cryptography.fields import encrypt
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from config.extras import BREWFATHER_APP_ROOT from config.extras import BREWFATHER_APP_ROOT
from beer.extras import sg_plato, plato_sg, kg_extract, convert
from django.conf import settings from django.conf import settings
import logging import logging
@ -36,47 +37,70 @@ class Batch(CustomModel):
recipe = models.OneToOneField( recipe = models.OneToOneField(
'Recipe', on_delete=models.CASCADE, default=1) 'Recipe', on_delete=models.CASCADE, default=1)
# -------------------------------------------------------------------------
# Brewday Measurements
# -------------------------------------------------------------------------
## Mash
first_runnings = models.DecimalField( first_runnings = models.DecimalField(
max_digits=8, decimal_places=4, null=True, blank=True) 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: ## Boil
# - Mash pH pre_boil_vol = models.DecimalField(
# - First Runnings gravity (include lookup table fo rmash thickness) max_digits=8, decimal_places=4, null=True, blank=True)
# - Boil Vol pre_boil_sg = models.DecimalField(
# - Pre-Boil Gravity max_digits=5, decimal_places=4, null=True, blank=True)
# - Post-Boil Vol post_boil_vol = models.DecimalField(
# - Post-Boil Gravity max_digits=8, decimal_places=4, null=True, blank=True)
# - Original Gravity post_boil_sg = models.DecimalField(
# - Final Gravity max_digits=5, decimal_places=4, null=True, blank=True)
# - Fermenter Top-Up
# - Fermenter Vol ## 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) # Properties Needed: (https://braukaiser.com/wiki/index.php/Troubleshooting_Brewhouse_Efficiency)
# - Conversion Efficiency
# - Mash 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 # - ABV
# - Attenuation # - Attenuation
# - Actual Boil-Off Rate # - Actual Boil-Off Rate
# - Actual Trub/Chiller Loss # - 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 @property
def conversion_efficiency(self): def conversion_efficiency(self):
""" Calculate conversion efficiency of mash.""" """ Calculate conversion efficiency of mash."""
strike_volume = (self.recipe.equipment.mash_ratio if self.first_runnings is None or self.recipe.fermentable_weight_kg == 0:
* self.recipe.fermentable_weight) return '-'
pot_yield = 0 return round((sg_plato(self.first_runnings)/self.recipe.fw_max)
for ferm in self.recipefermentable_set.all(): * (100-self.recipe.fw_max) / (100-sg_plato(self.first_runnings))
pot_yield += ferm.extract_potential * ferm.quantity , 4)
pot_yield = pot_yield / self.recipe.fermentable_weight @property
fw_max = pot_yield / (strike_volume+pot_yield) def boil_off_calcualted(self):
return float(self.pre_boil_vol - self.post_boil_vol) * .96
return (100 * (self.first_runnings/fw_max)
* (100-fw_max) / (100-self.first_runnings))
@property @property
def brewfather_url(self): def brewfather_url(self):
@ -157,16 +181,41 @@ class Recipe(CustomModel):
verbose_name_plural = 'Recipes' verbose_name_plural = 'Recipes'
@property @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.""" """Weight of all fermentables attached to recipe."""
aggregate = self.recipefermentable_set.all().aggregate(Sum('quantity')) 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 @property
def hop_weight(self): def hop_weight(self):
"""Weight of all fermentables attached to recipe.""" """Weight of all fermentables attached to recipe."""
aggregate = self.recipehop_set.all().aggregate(Sum('quantity')) aggregate = self.recipehop_set.all().aggregate(Sum('quantity'))
return aggregate['quantity__sum'] return float(aggregate['quantity__sum']) * 0.0352739619
@property @property
def final_volume(self): def final_volume(self):
@ -179,44 +228,43 @@ class Recipe(CustomModel):
@property @property
def sugar_yield(self): def sugar_yield(self):
"""Return point yield of all non-mashed ingredients.""" """Return point yield of all non-mashed ingredients."""
ferm_yield = 0
ferms = self.recipefermentable_set.all().select_related('fermentable') ferms = self.recipefermentable_set.all().select_related('fermentable')
sugars = ferms.filter(fermentable__fermentable_type=3) sugars = ferms.filter(fermentable__fermentable_type=3)
ferm_yield = 0
for f in sugars: for f in sugars:
ferm_yield += f.ferm_yield ferm_yield += f.extract_weight_kg
return float(ferm_yield) return float(ferm_yield)
@property @property
def mash_yield(self): def mash_yield(self):
"""Return point yield of all mashed ingredients.""" """Return point yield of all mashed ingredients."""
mash_yield = 0
ferms = self.recipefermentable_set.all().select_related('fermentable') ferms = self.recipefermentable_set.all().select_related('fermentable')
# Is not sugar (3)
mashed = ferms.filter(~Q(fermentable__fermentable_type=3)) mashed = ferms.filter(~Q(fermentable__fermentable_type=3))
mash_yield = 0
for f in mashed: for f in mashed:
mash_yield += f.ferm_yield mash_yield += f.extract_weight_kg * float(self.efficiency/100)
return float(mash_yield) return float(mash_yield)
@property @property
def original_sg(self): def original_sg(self):
"""Return original gravity.""" """Return original gravity."""
total_yield = self.sugar_yield + self.mash_yield total_extract = self.sugar_yield + self.mash_yield
gravity_points = total_yield/self.final_volume plato = 100 * total_extract / (self.final_volume + total_extract)
return round(1 + gravity_points/1000, 3) return round(plato_sg(plato), 3)
@property @property
def pre_boil_sg(self): def pre_boil_sg(self):
"""Return pre-boil gravity.""" """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 total_water = self.final_volume+self.boil_off_gph
gravity_points = total_yield/total_water plato = 100 * total_extract / (total_water + total_extract)
return round(1 + gravity_points/1000, 3) return round(plato_sg(plato), 3)
@property @property
def hop_water_loss(self): def hop_water_loss(self):
@ -320,7 +368,8 @@ class Fermentable(CustomIngredient):
fermentable_type = models.IntegerField(choices=types, default=1) fermentable_type = models.IntegerField(choices=types, default=1)
diastatic_power = models.DecimalField( diastatic_power = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) 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( protein = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True)
attenuation = models.DecimalField( attenuation = models.DecimalField(
@ -330,11 +379,15 @@ class Fermentable(CustomIngredient):
max_in_batch = models.DecimalField( max_in_batch = models.DecimalField(
max_digits=6, decimal_places=4, null=True, blank=True) max_digits=6, decimal_places=4, null=True, blank=True)
moisture = models.DecimalField( 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) non_fermentable = models.BooleanField(null=True, blank=True)
ibu_per_unit = models.DecimalField( ibu_per_unit = models.DecimalField(
max_digits=6, decimal_places=4, default=0) 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): def __str__(self):
return self.name return self.name
@ -345,32 +398,29 @@ class RecipeFermentable(CustomModel):
quantity = models.DecimalField(max_digits=6, decimal_places=4) quantity = models.DecimalField(max_digits=6, decimal_places=4)
# Properties Needed: # Properties Needed:
# - Extract Potential (1-moisture percent as decimal) * potential
# - The weight of extract in the grist # - The weight of extract in the grist
# extract in grist in kg = weight of grist in kg * extract potential # extract in grist in kg = weight of grist in kg * extract potential
@property @property
def extract_potential(self): def quantity_display(self):
return .8 * .96 return convert(self.quantity, 'kg', 'lb')
@property
def extract_weight_kg(self):
return float(self.quantity) * self.fermentable.extract_percent
@property @property
def percent(self): def percent(self):
return float(100 * self.quantity / self.recipe.fermentable_weight) return float(100 * self.quantity / self.recipe.fermentable_weight_kg)
@property @property
def lovibond_contributed(self): def lovibond_contributed(self):
srm_calc = (float(self.fermentable.lovibond) 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) 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): class Hop(CustomIngredient):
uses = { uses = {
@ -409,6 +459,11 @@ class RecipeHop(CustomModel):
time = models.IntegerField(default=60, validators=[MinValueValidator(0)]) time = models.IntegerField(default=60, validators=[MinValueValidator(0)])
use = models.IntegerField(choices=uses, default=1) use = models.IntegerField(choices=uses, default=1)
@property
def quantity_display(self):
"""Convert grams to ounces."""
return convert(self.quantity, 'g', 'oz')
@property @property
def ibu_tinseth(self): def ibu_tinseth(self):
type_bonus = { type_bonus = {
@ -424,8 +479,8 @@ class RecipeHop(CustomModel):
+ (self.recipe.original_sg-1)) / 2) + (self.recipe.original_sg-1)) / 2)
if self.use == 1: if self.use == 1:
conc = (float((self.hop.alpha/100) * self.quantity) conc = (float((self.hop.alpha/100)) * float(self.quantity)*0.0352739619
* 7490/self.recipe.final_volume) * 7490/convert(self.recipe.final_volume, 'l', 'gal'))
util = (hop_bonus*1.65*0.000125**average_wort_sg util = (hop_bonus*1.65*0.000125**average_wort_sg
* ((1-2.71828182845904**(-0.04*self.time)) / 4.15)) * ((1-2.71828182845904**(-0.04*self.time)) / 4.15))
ibu = conc * util ibu = conc * util

View File

@ -88,8 +88,8 @@ font-size: .8em;
<caption> <caption>
<p class="text-end smaller"> <p class="text-end smaller">
Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg }}</b><br> Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg|floatformat:3 }}</b><br>
Original Gravity: <b>{{ recipe.original_sg }}</b><br> Original Gravity: <b>{{ recipe.original_sg|floatformat:3 }}</b><br>
Color: <b>{{ recipe.srm|floatformat:0 }} SRM</b> Color: <b>{{ recipe.srm|floatformat:0 }} SRM</b>
</p> </p>
</caption> </caption>
@ -97,9 +97,9 @@ font-size: .8em;
<tbody> <tbody>
{% for f in recipe.recipefermentable_set.all %} {% for f in recipe.recipefermentable_set.all %}
<tr onclick="window.location='{% url 'beer:update_fermentable' f.fermentable.id %}';"> <tr onclick="window.location='{% url 'beer:update_fermentable' f.fermentable.id %}';">
<td>{{ f.quantity|floatformat:2 }} lb</td> <td>{{ f.quantity_display|floatformat:2 }} lb</td>
<td>{{ f.fermentable.name }}<br> <td>{{ f.fermentable.name }}<br>
<span class="text-muted">{{ f.fermentable.get_fermentable_type_display }} {{ f.srm }} SRM</span> <span class="text-muted">{{ f.fermentable.get_fermentable_type_display }} {{ f.lovibond_contributed }} L</span>
</td> </td>
<td>{{ f.percent|floatformat:1 }} %</td> <td>{{ f.percent|floatformat:1 }} %</td>
</tr> </tr>
@ -132,7 +132,7 @@ font-size: .8em;
<tbody> <tbody>
{% for h in recipe.recipehop_set.all %} {% for h in recipe.recipehop_set.all %}
<tr onclick="window.location='{% url 'beer:update_hop' h.hop.id %}';"> <tr onclick="window.location='{% url 'beer:update_hop' h.hop.id %}';">
<td>{{ h.quantity|floatformat:2 }} oz</td> <td>{{ h.quantity_display|floatformat:2 }} oz</td>
<td>{{ h.hop.name }} {{ h.hop.alpha|floatformat:1 }} %<br> <td>{{ h.hop.name }} {{ h.hop.alpha|floatformat:1 }} %<br>
<span class="text-muted">{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU</span> <span class="text-muted">{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU</span>
</td> </td>