diff --git a/db/backup/batches.json b/db/backup/batches.json index 9160dc7..f58dbc6 100644 --- a/db/backup/batches.json +++ b/db/backup/batches.json @@ -19,13 +19,13 @@ "Ferment #1 output": { "name": "Ferment #1 output", "metrics": { - "ORIGINAL_GRAVITY": { - "amount": "79.16666666666667", + "GRAVITY": { + "amount": "12.999999999999886", "unit": "GU", "estimate": false }, - "GRAVITY": { - "amount": "12.999999999999886", + "ORIGINAL_GRAVITY": { + "amount": "79.16666666666667", "unit": "GU", "estimate": false }, @@ -40,13 +40,13 @@ "Ferment #2 output": { "name": "Ferment #2 output", "metrics": { - "ORIGINAL_GRAVITY": { - "amount": "34.375", + "GRAVITY": { + "amount": "12.999999999999886", "unit": "GU", "estimate": false }, - "GRAVITY": { - "amount": "12.999999999999886", + "ORIGINAL_GRAVITY": { + "amount": "34.375", "unit": "GU", "estimate": false }, @@ -87,15 +87,15 @@ "unit": "MILLILITRES", "estimate": false }, - "GRAVITY": { - "amount": "33.0", - "unit": "GU", - "estimate": false - }, "TEMPERATURE": { "amount": "100.0", "unit": "CELSIUS", "estimate": false + }, + "GRAVITY": { + "amount": "33.0", + "unit": "GU", + "estimate": false } }, "type": "WORT" @@ -108,15 +108,15 @@ "unit": "MILLILITRES", "estimate": false }, - "GRAVITY": { - "amount": "76.0", - "unit": "GU", - "estimate": false - }, "TEMPERATURE": { "amount": "100.0", "unit": "CELSIUS", "estimate": false + }, + "GRAVITY": { + "amount": "76.0", + "unit": "GU", + "estimate": false } }, "type": "WORT" @@ -124,11 +124,6 @@ "Table Beer": { "name": "Table Beer", "metrics": { - "ORIGINAL_GRAVITY": { - "amount": "34.375", - "unit": "GU", - "estimate": false - }, "VOLUME": { "amount": "15000.0", "unit": "MILLILITRES", @@ -139,6 +134,11 @@ "unit": "GU", "estimate": false }, + "ORIGINAL_GRAVITY": { + "amount": "34.375", + "unit": "GU", + "estimate": false + }, "ABV": { "amount": "0.02805468750000019", "unit": "PERCENTAGE", @@ -177,11 +177,6 @@ "Nagasaki Sauce XX DIPA": { "name": "Nagasaki Sauce XX DIPA", "metrics": { - "ORIGINAL_GRAVITY": { - "amount": "79.16666666666667", - "unit": "GU", - "estimate": false - }, "VOLUME": { "amount": "18000.0", "unit": "MILLILITRES", @@ -192,6 +187,11 @@ "unit": "GU", "estimate": false }, + "ORIGINAL_GRAVITY": { + "amount": "79.16666666666667", + "unit": "GU", + "estimate": false + }, "ABV": { "amount": "0.08684375000000034", "unit": "PERCENTAGE", diff --git a/db/backup/recipes.json b/db/backup/recipes.json index 5e57802..206b5e4 100644 --- a/db/backup/recipes.json +++ b/db/backup/recipes.json @@ -1,4 +1,151 @@ [ + { + "equipmentProfile": "All Grain - 40l Plastic Mash Tun", + "name": "Test Recipe", + "steps": [ + { + "outputFirstRunnings": "Mash #1 first runnings", + "duration": 60.0, + "grainTemp": 20.0, + "name": "Mash #1", + "description": "Initial mash infusion", + "ingredients": [ + { + "quantity": { + "amount": "5000.0", + "unit": "GRAMS", + "estimate": true + }, + "fermentable": "Pale Malt (2 Row) Bel", + "name": "Pale Malt (2 Row) Bel", + "time": 3600.0, + "type": "FERMENTABLES" + }, + { + "quantity": { + "amount": "30000.0", + "unit": "MILLILITRES", + "estimate": true + }, + "name": "Default Water", + "temperature": 70.0, + "time": 3600.0, + "type": "WATER", + "water": "Default Water" + }, + { + "quantity": { + "amount": "1.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "Allspice (dry ground)", + "time": 3600.0, + "type": "MISC", + "misc": "Allspice (dry ground)" + }, + { + "quantity": { + "amount": "1.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "African Queen", + "hop": "African Queen", + "time": 3600.0, + "type": "HOPS" + } + ], + "outputMashVolume": "Mash #1 mash vol", + "type": "MASH" + }, + { + "mashVolume": "Mash #1 mash vol", + "name": "Batch Sparge #1", + "outputSpargeRunnings": "Batch Sparge #1 lautered mash", + "description": "Batch Sparge", + "ingredients": [ + { + "quantity": { + "amount": "10000.0", + "unit": "MILLILITRES", + "estimate": true + }, + "name": "Default Water", + "temperature": 75.0, + "time": 0.0, + "type": "WATER", + "water": "Default Water" + } + ], + "outputMashVolume": "Batch Sparge #1 sparge runnings", + "outputCombinedWortVolume": "Batch Sparge #1 combined wort", + "type": "BATCH_SPARGE", + "wortVolume": "Mash #1 first runnings" + }, + { + "duration": 60.0, + "outputWortVolume": "Boil #1 output", + "inputWortVolume": "Batch Sparge #1 combined wort", + "name": "Boil #1", + "description": "Boil", + "ingredients": [ + { + "quantity": { + "amount": "30.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "African Queen", + "hop": "African Queen", + "time": 3600.0, + "type": "HOPS" + } + ], + "type": "BOIL" + }, + { + "targetTemp": 20.0, + "name": "Cool #1", + "description": "Cool", + "ingredients": [], + "outputVolume": "Cool #1 output", + "type": "COOL", + "inputVolume": "Boil #1 output" + }, + { + "duration": 14.0, + "temp": 20.0, + "name": "Ferment #1", + "description": "Ferment", + "ingredients": [ + { + "quantity": { + "amount": "11.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "Safale American", + "time": 1209600.0, + "type": "YEAST", + "yeast": "Safale American" + } + ], + "outputVolume": "Ferment #1 output", + "type": "FERMENT", + "inputVolume": "Cool #1 output" + }, + { + "packagingLoss": 500.0, + "name": "Package #1", + "description": "Package", + "ingredients": [], + "outputVolume": "My Beer", + "type": "PACKAGE", + "inputVolume": "Ferment #1 output" + } + ] + }, { "equipmentProfile": "All Grain - 40l Plastic Mash Tun", "name": "Nagasaki Sauce XX", diff --git a/db/batches.json b/db/batches.json index c02b092..e262c7d 100644 --- a/db/batches.json +++ b/db/batches.json @@ -19,6 +19,11 @@ "Ferment #1 output": { "name": "Ferment #1 output", "metrics": { + "ABV": { + "amount": "0.08684375000000034", + "unit": "PERCENTAGE", + "estimate": false + }, "GRAVITY": { "amount": "12.999999999999886", "unit": "GU", @@ -28,11 +33,6 @@ "amount": "79.16666666666667", "unit": "GU", "estimate": false - }, - "ABV": { - "amount": "0.08684375000000034", - "unit": "PERCENTAGE", - "estimate": false } }, "type": "BEER" @@ -40,6 +40,11 @@ "Ferment #2 output": { "name": "Ferment #2 output", "metrics": { + "ABV": { + "amount": "0.02805468750000019", + "unit": "PERCENTAGE", + "estimate": false + }, "GRAVITY": { "amount": "12.999999999999886", "unit": "GU", @@ -49,11 +54,6 @@ "amount": "34.375", "unit": "GU", "estimate": false - }, - "ABV": { - "amount": "0.02805468750000019", - "unit": "PERCENTAGE", - "estimate": false } }, "type": "BEER" @@ -82,16 +82,16 @@ "Boil #2 output": { "name": "Boil #2 output", "metrics": { - "TEMPERATURE": { - "amount": "100.0", - "unit": "CELSIUS", - "estimate": false - }, "VOLUME": { "amount": "17000.0", "unit": "MILLILITRES", "estimate": false }, + "TEMPERATURE": { + "amount": "100.0", + "unit": "CELSIUS", + "estimate": false + }, "GRAVITY": { "amount": "33.0", "unit": "GU", @@ -103,16 +103,16 @@ "Boil #1 output": { "name": "Boil #1 output", "metrics": { - "TEMPERATURE": { - "amount": "100.0", - "unit": "CELSIUS", - "estimate": false - }, "VOLUME": { "amount": "22000.0", "unit": "MILLILITRES", "estimate": false }, + "TEMPERATURE": { + "amount": "100.0", + "unit": "CELSIUS", + "estimate": false + }, "GRAVITY": { "amount": "76.0", "unit": "GU", @@ -124,6 +124,11 @@ "Table Beer": { "name": "Table Beer", "metrics": { + "ABV": { + "amount": "0.02805468750000019", + "unit": "PERCENTAGE", + "estimate": false + }, "VOLUME": { "amount": "15000.0", "unit": "MILLILITRES", @@ -138,11 +143,6 @@ "amount": "34.375", "unit": "GU", "estimate": false - }, - "ABV": { - "amount": "0.02805468750000019", - "unit": "PERCENTAGE", - "estimate": false } }, "type": "BEER" @@ -177,6 +177,11 @@ "Nagasaki Sauce XX DIPA": { "name": "Nagasaki Sauce XX DIPA", "metrics": { + "ABV": { + "amount": "0.08684375000000034", + "unit": "PERCENTAGE", + "estimate": false + }, "VOLUME": { "amount": "18000.0", "unit": "MILLILITRES", @@ -191,11 +196,6 @@ "amount": "79.16666666666667", "unit": "GU", "estimate": false - }, - "ABV": { - "amount": "0.08684375000000034", - "unit": "PERCENTAGE", - "estimate": false } }, "type": "BEER" diff --git a/db/recipes.json b/db/recipes.json index 83db0f7..515d81c 100644 --- a/db/recipes.json +++ b/db/recipes.json @@ -32,6 +32,28 @@ "time": 3600.0, "type": "WATER", "water": "Default Water" + }, + { + "quantity": { + "amount": "1.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "Allspice (dry ground)", + "time": 3600.0, + "type": "MISC", + "misc": "Allspice (dry ground)" + }, + { + "quantity": { + "amount": "1.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "African Queen", + "hop": "African Queen", + "time": 3600.0, + "type": "HOPS" } ], "outputMashVolume": "Mash #1 mash vol", @@ -78,6 +100,17 @@ "hop": "African Queen", "time": 3600.0, "type": "HOPS" + }, + { + "quantity": { + "amount": "1.0", + "unit": "GRAMS", + "estimate": true + }, + "name": "Anise, Star", + "time": 0.0, + "type": "MISC", + "misc": "Anise, Star" } ], "type": "BOIL" diff --git a/src/mclachlan/brewday/math/Equations.java b/src/mclachlan/brewday/math/Equations.java index 01a3ecc..9e48614 100644 --- a/src/mclachlan/brewday/math/Equations.java +++ b/src/mclachlan/brewday/math/Equations.java @@ -135,6 +135,28 @@ public static ColourUnit calcCombinedColour( estimated); } + /*-------------------------------------------------------------------------*/ + /** + * Calculates the bitterness of the combined fluids. + * Source: I made this up + * @return New bitterness of the combined volume. + */ + public static BitternessUnit calcCombinedBitterness( + VolumeUnit v1, + BitternessUnit b1, + VolumeUnit v2, + BitternessUnit b2) + { + boolean estimated = v1.isEstimated() || b1.isEstimated() || v2.isEstimated() || b2.isEstimated(); + + return new BitternessUnit( + (v1.get() + v2.get()) / + (v1.get()/b1.get() + + + v2.get()/b2.get()), + b1.getUnit(), + estimated); + } /*-------------------------------------------------------------------------*/ /** @@ -293,23 +315,25 @@ public static ColourUnit calcColourSrmMoreyFormula( public static ColourUnit calcColourAfterBoil(ColourUnit colourIn) { // - // Brewday has an issue with colour calculations: existing formulae (eg Morey) - // require the use of MCUs based on post-boil gravity. But Brewday can't - // easily do that because the process steps are decoupled and there isn't - // necessarily a 1:1 mapping from mash to boil. + // Brewday has an issue with colour calculations: existing formulae (eg + // Morey) require the use of MCUs based on post-boil gravity. + // (source: http://www.beersmith.com/forum/index.php?topic=5797.0) + // But Brewday can't easily do that because the process steps are + // decoupled and there isn't necessarily a 1:1 mapping from mash to boil. // - // One option would be passing MCUs around in volumes waiting to arrive at a - // post-boil gravity. I doubt this would work properly and haven't tried it - // yet. + // One option would be passing MCUs around as a metric in the volumes, + // waiting to arrive at a post-boil volume. I doubt this would work + // properly and haven't tried it yet. // - // Instead I'm doing this: the typical brew process produces a post-boil + // Instead I'm doing this: the typical homebrew process produces a post-boil // volume about 60% of the input water. Working out a table of SRM values // shows me that the SRM output is 42% higher when the MCU's are worked - // out with 60% of the water. + // out with 60% of the water volume. // So to model this in Brewday at boil time we increase the SRM by 42%. // // This is kinda wacky I admit. But to quote Palmer, there are "inherent - // limits of any model for beer colour". + // limits of any model for beer colour" so I guess it's best to be a bit + // relaxed about this stuff. // double srmIn = colourIn.get(Quantity.Unit.SRM); @@ -337,6 +361,30 @@ public static ColourUnit calcColourWithVolumeChange( estimated); } + /*-------------------------------------------------------------------------*/ + /** + * @param volumeIn assumed IBU of 0 + */ + public static BitternessUnit calcBitternessWithVolumeChange( + VolumeUnit volumeIn, + BitternessUnit bitternessIn, + VolumeUnit volumeOut) + { + if (bitternessIn == null) + { + return new BitternessUnit(0); + } + + boolean estimated = volumeIn.isEstimated() || bitternessIn.isEstimated() || volumeOut.isEstimated(); + + return new BitternessUnit( + bitternessIn.get(Quantity.Unit.IBU) * + volumeIn.get(Quantity.Unit.MILLILITRES) / + volumeOut.get(Quantity.Unit.MILLILITRES), + Quantity.Unit.IBU, + estimated); + } + /*-------------------------------------------------------------------------*/ /** * @param colour in SRM @@ -383,6 +431,29 @@ public static BitternessUnit calcIbuTinseth( estimated); } + /*-------------------------------------------------------------------------*/ + public static BitternessUnit calcMashHopIbu( + List hopAdditions, + DensityUnit wortDensity, + VolumeUnit wortVolume, + double equipmentHopUtilisation) + { + BitternessUnit bitternessOut = new BitternessUnit(0); + for (IngredientAddition hopCharge : hopAdditions) + { + bitternessOut.add( + Equations.calcIbuTinseth( + (HopAddition)hopCharge, + hopCharge.getTime(), + wortDensity, + wortVolume, + equipmentHopUtilisation)); + } + + // mash hop bitterness adjustment is -80% (source: BeerSmith) + return new BitternessUnit(bitternessOut.get()*0.2D); + } + /*-------------------------------------------------------------------------*/ /** * Given grain and water, returns the resultant mash temp. diff --git a/src/mclachlan/brewday/process/BatchSparge.java b/src/mclachlan/brewday/process/BatchSparge.java index 55f1a54..d1ae95f 100644 --- a/src/mclachlan/brewday/process/BatchSparge.java +++ b/src/mclachlan/brewday/process/BatchSparge.java @@ -153,7 +153,6 @@ else if (item instanceof FermentableAddition) Quantity.Unit.MILLILITRES, false); - // model the batch sparge as a dilution of the extract remaining DensityUnit spargeGravity = Equations.calcGravityWithVolumeChange( @@ -195,6 +194,12 @@ else if (item instanceof FermentableAddition) // the existing wort colour, diluted by the sparge water, plus an top up grains colour ColourUnit spargeColour = new ColourUnit(dilutedColour.get() + addedColour.get()); + // work out the diluted bitterness + BitternessUnit bitternessOut = Equations.calcBitternessWithVolumeChange( + mash.getVolume(), + mash.getBitterness(), + volumeOut); + // output the lautered mash volume, in case it needs to be input into further batch sparge steps Volume lauteredMashVolume = new Volume( outputMashVolume, @@ -205,6 +210,7 @@ else if (item instanceof FermentableAddition) mash.getTemperature(), spargeGravity, spargeColour); + lauteredMashVolume.setBitterness(bitternessOut); volumes.addOrUpdateVolume(outputMashVolume, lauteredMashVolume); @@ -218,7 +224,7 @@ else if (item instanceof FermentableAddition) spargeGravity, inputWort.getAbv(), spargeColour, - inputWort.getBitterness()); + bitternessOut); volumes.addOrUpdateVolume(outputSpargeRunnings, isolatedSpargeRunnings); @@ -229,6 +235,10 @@ else if (item instanceof FermentableAddition) inputWort.getVolume(), inputWort.getColour(), isolatedSpargeRunnings.getVolume(), isolatedSpargeRunnings.getColour()); + BitternessUnit combinedBitterness = Equations.calcCombinedBitterness( + inputWort.getVolume(), inputWort.getBitterness(), + isolatedSpargeRunnings.getVolume(), isolatedSpargeRunnings.getBitterness()); + Volume combinedWort = new Volume( outputCombinedWortVolume, Volume.Type.WORT, @@ -238,7 +248,7 @@ else if (item instanceof FermentableAddition) gravityOut, new PercentageUnit(0D), combinedColour, - new BitternessUnit(0D)); + combinedBitterness); volumes.addOrUpdateVolume(outputCombinedWortVolume, combinedWort); } diff --git a/src/mclachlan/brewday/process/Dilute.java b/src/mclachlan/brewday/process/Dilute.java index 761056e..feb9e86 100644 --- a/src/mclachlan/brewday/process/Dilute.java +++ b/src/mclachlan/brewday/process/Dilute.java @@ -106,12 +106,17 @@ public void apply(Volumes volumes, EquipmentProfile equipmentProfile, ProcessLo PercentageUnit abvOut = Equations.calcAbvWithVolumeChange( input.getVolume(), input.getAbv(), volumeOut); - // assuming the water is at zero SRM - ColourUnit colourOut = Equations.calcColourWithVolumeChange( - input.getVolume(), input.getColour(), volumeOut); + // assuming the water is at zero SRM and zero IBU - // todo: account for bitterness reduction - BitternessUnit bitternessOut = new BitternessUnit(input.getBitterness()); + ColourUnit colourOut = Equations.calcColourWithVolumeChange( + input.getVolume(), + input.getColour(), + volumeOut); + BitternessUnit bitternessOut = + Equations.calcBitternessWithVolumeChange( + input.getVolume(), + input.getBitterness(), + volumeOut); volumes.addOrUpdateVolume( getOutputVolume(), diff --git a/src/mclachlan/brewday/process/Mash.java b/src/mclachlan/brewday/process/Mash.java index c70ae0f..b2c6bd2 100644 --- a/src/mclachlan/brewday/process/Mash.java +++ b/src/mclachlan/brewday/process/Mash.java @@ -21,10 +21,7 @@ import mclachlan.brewday.StringUtils; import mclachlan.brewday.equipment.EquipmentProfile; import mclachlan.brewday.math.*; -import mclachlan.brewday.recipe.FermentableAddition; -import mclachlan.brewday.recipe.IngredientAddition; -import mclachlan.brewday.recipe.Recipe; -import mclachlan.brewday.recipe.WaterAddition; +import mclachlan.brewday.recipe.*; import static mclachlan.brewday.math.Quantity.Unit.*; @@ -101,6 +98,7 @@ public void apply(Volumes volumes, EquipmentProfile equipmentProfile, ProcessLo } List grainBill = new ArrayList<>(); + List hopCharges = new ArrayList<>(); WaterAddition strikeWater = null; for (IngredientAddition item : getIngredients()) @@ -118,6 +116,10 @@ else if (item instanceof WaterAddition) { strikeWater = (WaterAddition)item; } + else if (item instanceof HopAddition) + { + hopCharges.add((HopAddition)item); + } } } @@ -132,6 +134,8 @@ else if (item instanceof WaterAddition) return; } + + Volume mashVolumeOut = getMashVolumeOut(equipmentProfile, grainBill, strikeWater); volumes.addOrUpdateVolume(outputMashVolume, mashVolumeOut); @@ -145,6 +149,16 @@ else if (item instanceof WaterAddition) Volume firstRunningsOut = getFirstRunningsOut(mashVolumeOut, grainBill, equipmentProfile); volumes.addOrUpdateVolume(outputFirstRunnings, firstRunningsOut); + + // mash hops + BitternessUnit bitterness = Equations.calcMashHopIbu( + hopCharges, + firstRunningsOut.getGravity(), + firstRunningsOut.getVolume(), + equipmentProfile.getHopUtilisation()); + + mashVolumeOut.setBitterness(new BitternessUnit(bitterness)); + firstRunningsOut.setBitterness(new BitternessUnit(bitterness)); } /*-------------------------------------------------------------------------*/ @@ -301,9 +315,9 @@ public Collection getOutputVolumes() @Override public List getSupportedIngredientAdditions() { - // todo: mash hops return Arrays.asList( IngredientAddition.Type.FERMENTABLES, + IngredientAddition.Type.HOPS, IngredientAddition.Type.MISC, IngredientAddition.Type.WATER); } @@ -336,6 +350,26 @@ public List getInstructions() this.grainTemp.get(Quantity.Unit.CELSIUS))); } + for (IngredientAddition ia : getIngredientAdditions(IngredientAddition.Type.HOPS)) + { + result.add( + StringUtils.getDocString( + "mash.hop.addition", + ia.getQuantity().get(GRAMS), + ia.getName(), + ia.getTime().get(MINUTES))); + } + + for (IngredientAddition ia : getIngredientAdditions(IngredientAddition.Type.MISC)) + { + result.add( + StringUtils.getDocString( + "mash.misc.addition", + ia.getQuantity().get(GRAMS), + ia.getName(), + ia.getTime().get(MINUTES))); + } + String outputMashVolume = this.getOutputMashVolume(); Volume mashVol = getRecipe().getVolumes().getVolume(outputMashVolume); diff --git a/strings/document.properties b/strings/document.properties index 7bbf913..91b0baf 100644 --- a/strings/document.properties +++ b/strings/document.properties @@ -6,6 +6,8 @@ with_freemarker = with Freemarker # mash & mash infusion steps mash.fermentable.addition = add %.2fkg %s at %.1fC +mash.hop.addition = add %.1fg %s at %.0f minutes +mash.misc.addition = add %.1fg %s at %.0f minutes mash.water.addition = add %.1fL %s at %.1fC mash.volume = mash volume %.1fL at %.1fC mash.rest = rest for %.0f minutes diff --git a/stufftodo.txt b/stufftodo.txt index fd30406..c4168a7 100644 --- a/stufftodo.txt +++ b/stufftodo.txt @@ -1,20 +1,17 @@ water chemistry & pH -mash hop, misc additions -boil misc additions persistence back ends: Github, Dropbox --------------------- first wort hop additions right click menu for ingredient additions/step options import from beersmith packaging 2: javapackager? -fix dilute bitterness impact hop stand bitterness fermentation temperature steps fix yeast pitch rate impact batch/inventory integration shopping list document generation --------------------- -package step: kegging, krausen and other carbonation methods +package step: kegging, krausening and other carbonation methods extract & partial mash brewing tools on the step pages: eg target mash temp UI support for user selected units of measurement