Compare commits

...

3 Commits

Author SHA1 Message Date
90bf91c3ed Added additional database fields and properties.
Much calculation of efficiency and stuff now.
2024-06-26 11:48:34 -04:00
570ede627f Some more handy helper functions. 2024-06-26 11:47:04 -04:00
07c881bdfe SG and Plato conversion calcs. 2024-06-25 14:48:59 -04:00
6 changed files with 310 additions and 64 deletions

View File

@ -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 = ('<a href="{root}/tabs/batches/batch/{batch_id}">'
'Brewfather Batch ID: {batch_id}</a>')

View File

@ -9,6 +9,7 @@ RECIPE_URL = 'https://api.brewfather.app/v2/recipes'
BATCH_URL = 'https://api.brewfather.app/v2/batches'
PULL_LIMIT = 50
BREWFATHER_CONVERT_LOOKUP = { # local_name: brewfather_name
'all': {
'name': 'name',
@ -86,3 +87,77 @@ def get_batches(api_user, api_key, batch=''):
data = data + get_batches(batch=last_id)
return data
def sg_plato(sg):
"""Convert specific gravtiy to °P."""
sg = float(sg)
return (-1*616.868) + (1111.14*sg) - (630.272*sg**2) + (135.997*sg**3)
def plato_sg(plato):
"""Convert °P to specific gravtiy."""
return 1 + (plato / (258.6 - (plato/258.2) * 227.1))
def kg_extract(v, sg):
"""Calculate kg of extract based on volume and SG."""
return float(v) * float(sg) * sg_plato(sg)/100
def convert(value, start_unit, end_unit):
"""Convert units."""
family_dict = unit_family(start_unit)
if family_dict:
start = [val for key, val in family_dict.items()
if start_unit in key.split(',')][0]
end = [val for key, val in family_dict.items()
if end_unit in key.split(',')][0]
return float(value) * end / start
def unit_family(unit_name):
"""Find unit family base on unit name."""
unit_lookup = [
{'name': 'length',
'units': {
'm,meter,meters': 1.0,
'cm,centimeter,centimeters': 100,
'mm,millimeter,millimeters': 1000,
'in,inch,inches': 39.3701,
'yd,yard,yards': 1.09361,
'ft,foot,feet': 3.28084,
'mi,mile,miles': 0.000621371,
'km,killometer,killometers': .001,
}},
{'name': 'mass',
'units': {
'kg,kilogram,kilograms': 1.0,
'g,gram,grams': 1000,
'lb,pound,pounds': 2.20462,
'oz,ounce,ounces': 35.274,
'st,stone': 0.157473,
}},
{'name': 'volume',
'units': {
'l,liter,liters': 1.0,
'ml,milliliter,milliliters': 1000,
'floz,fluid ounce,fluid ounces': 33.814,
'cup,cups': 4.22675,
'qt,quart,quarts': 1.05669,
'gal,gallon,gallons': 0.264172,
'ft^3,cubic foot,cubic feet': 0.0353147,
'pt,pint,pints': 4.22675/2,
'tsp,teaspoon,teaspoons': 202.884,
'tbsp,tablespoon,tablespoons': 202.884/3,
}},
]
for family in unit_lookup:
if [key for key in family['units'] if unit_name in key.split(',')]:
return family['units']

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 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

View File

@ -88,8 +88,8 @@ font-size: .8em;
<caption>
<p class="text-end smaller">
Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg }}</b><br>
Original Gravity: <b>{{ recipe.original_sg }}</b><br>
Pre-Boil Gravity: <b>{{ recipe.pre_boil_sg|floatformat:3 }}</b><br>
Original Gravity: <b>{{ recipe.original_sg|floatformat:3 }}</b><br>
Color: <b>{{ recipe.srm|floatformat:0 }} SRM</b>
</p>
</caption>
@ -97,9 +97,9 @@ font-size: .8em;
<tbody>
{% for f in recipe.recipefermentable_set.all %}
<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>
<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>{{ f.percent|floatformat:1 }} %</td>
</tr>
@ -132,7 +132,7 @@ font-size: .8em;
<tbody>
{% for h in recipe.recipehop_set.all %}
<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>
<span class="text-muted">{{ h.hop.get_hop_type_display }} {{ h.ibu_tinseth|floatformat:1 }} IBU</span>
</td>