├── .editorconfig ├── .flowconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── .env ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── components │ ├── CrossUnitsInput.js │ ├── CrossUnitsInput.test.js │ ├── Ingredients.js │ ├── MashSteps.js │ ├── Recipe.js │ ├── Recipe.test.js │ ├── RecipeSpecs.js │ ├── RecipeSpecs.test.js │ ├── Stats.js │ └── __snapshots__ │ │ ├── CrossUnitsInput.test.js.snap │ │ ├── Recipe.test.js.snap │ │ └── RecipeSpecs.test.js.snap │ ├── containers │ ├── EditorContainer.js │ ├── ImportArea.js │ └── PageRecipe.js │ ├── data │ ├── equipment.js │ └── recipe.js │ ├── index.css │ ├── index.js │ ├── redux │ └── reducers │ │ └── updateEditor.js │ ├── registerServiceWorker.js │ └── setupTests.js ├── jest.config.js ├── jest.transform.js ├── package-lock.json ├── package.json ├── src ├── abv.ts ├── carbonation.ts ├── color.ts ├── converter │ ├── converter.ts │ └── definitions.ts ├── culture.ts ├── gravity.ts ├── hops.ts ├── index.ts ├── mash.ts ├── timing.ts ├── types │ ├── beerjson.ts │ ├── saltAdditions.ts │ ├── water.ts │ └── yeast.ts ├── units.ts ├── utils.ts ├── volumes.ts └── waterChem.ts ├── tests ├── abv.test.ts ├── bitterness.test.ts ├── brewcalc.test.ts ├── color.test.ts ├── converter.test.ts ├── data │ ├── AussieAle.ts │ ├── BrewcalcTest.json │ ├── MuddyPig.ts │ ├── TestBeerJSON.json │ ├── TestEquipment.ts │ └── TestRecipeConverted.ts ├── gravity.test.ts ├── mash.test.ts ├── volumes.test.ts └── waterChem.test.ts ├── tsconfig.json ├── web ├── brewcalc.js └── brewcalc.min.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = LF 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.* 3 | ./app/.* 4 | [include] 5 | 6 | [libs] 7 | 8 | [options] 9 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: brewcalc tests 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-and-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 14 18 | - run: npm install 19 | - run: npm run test 20 | - run: npm run pack:app 21 | - run: cd app && npm install ./brewcalc.tgz 22 | - run: cd app && npm run test 23 | - run: cd app && npm run build 24 | 25 | - name: Deploy 🚀 26 | uses: JamesIves/github-pages-deploy-action@3.7.1 27 | with: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | BRANCH: gh-pages 30 | FOLDER: app/build 31 | CLEAN: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Remove some common IDE working directories 30 | .idea 31 | .vscode 32 | 33 | # Package tarball for test app 34 | package 35 | 36 | # Bundled lib 37 | lib 38 | 39 | # npm tarball 40 | *.tgz 41 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .babelrc 3 | .editorconfig 4 | .flowconfig 5 | .github 6 | 7 | *.tgz 8 | 9 | app 10 | src 11 | web 12 | tests 13 | 14 | tsconfig.json 15 | webpack.* 16 | jest.* 17 | 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "jsxBracketSameLine": true, 5 | "tabWidth": 2, 6 | "overrides": [ 7 | { 8 | "files": "*.ts", 9 | "options": { 10 | "parser": "babel-ts" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 brewcomputer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍺 brewcalc ![brewcalc tests](https://github.com/brewcomputer/brewcalc/workflows/brewcalc%20tests/badge.svg) 2 | A modern (ES6) functional JavaScript library for brewing calculations. 3 | 4 | ▶️ Try it online 5 | -------------------- 6 | * [Online recipe calculator](https://brewcomputer.github.io/brewcalc/) - import any BeerXML file or edit recipe JSON online. 7 | * [JSFiddle sample](https://jsfiddle.net/krutilin/nn7sdekg/) 8 | 9 | 🚀 Installation 10 | ------------ 11 | There are two ways to use **brewcalc** - client-side in a web browser or server-side using node.js. 12 | 13 | ### Browser 14 | To use **brewcalc** in a browser, download compiled lib and include it to your html: 15 | 16 | * [The latest brewcalc.min.js](https://raw.githubusercontent.com/brewcomputer/brewcalc/master/web/brewcalc.min.js) 17 | 18 | ```html 19 | 20 | ``` 21 | 22 | ### Node.js 23 | For Node.js, you can install **brewcalc** using `npm`: 24 | 25 | ```bash 26 | npm install brewcalc 27 | ``` 28 | add `--save` option to add brewcalc to your project's package.json 29 | 30 | ## Input data types 31 | For expected input data types please refer to [FlowType definitions](https://github.com/brewcomputer/brewcalc/tree/master/src/types). 32 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /app/.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | build/* -------------------------------------------------------------------------------- /app/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "env": { 4 | "amd": true 5 | }, 6 | "rules": { 7 | "semi": [ 8 | "error", 9 | "never" 10 | ], 11 | "quotes": [ 12 | "error", 13 | "single", 14 | { 15 | "allowTemplateLiterals": true 16 | } 17 | ], 18 | "jsx-a11y/href-no-hash": "off", 19 | "jsx-a11y/anchor-is-valid": "off" 20 | }, 21 | "plugins": [ 22 | "prettier" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /app/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.* 3 | [include] 4 | 5 | [libs] 6 | 7 | [options] 8 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | *.tgz 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brewcalc-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@beerjson/beerjson": "1.0.1", 7 | "ace-builds": "1.4.12", 8 | "brewcalc": "file:brewcalc.tgz", 9 | "prop-types": "15.7.2", 10 | "react": "16.14.0", 11 | "react-ace": "9.4.3", 12 | "react-bootstrap": "1.6.3", 13 | "react-dom": "16.14.0", 14 | "react-redux": "7.2.5", 15 | "react-test-renderer": "16.14.0", 16 | "redux": "4.1.1" 17 | }, 18 | "devDependencies": { 19 | "eslint-plugin-prettier": "^3.0.1", 20 | "gh-pages": "2.2.0", 21 | "prettier": "2.3.2", 22 | "react-scripts": "3.4.4" 23 | }, 24 | "scripts": { 25 | "lint": "eslint .", 26 | "test": "react-scripts test --env=jsdom", 27 | "coverage": "react-scripts test --env=jsdom --coverage", 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "predeploy": "npm run build", 31 | "deploy": "gh-pages -d build" 32 | }, 33 | "homepage": "http://brewcomputer.github.io/brewcalc", 34 | "browserslist": [ 35 | ">0.2%", 36 | "not dead", 37 | "not ie <= 11", 38 | "not op_mini all" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brewcomputer/brewcalc/0550ef4d77ce9c979924642dd784f447eba61405/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | 24 | 25 | 26 | 27 | 28 | BrewCalc 29 | 30 | 31 | 32 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /app/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { createStore } from 'redux' 4 | import PageRecipe from './containers/PageRecipe' 5 | import updateEditor from './redux/reducers/updateEditor' 6 | import { persistedState } from './redux/reducers/updateEditor' 7 | 8 | const store = createStore( 9 | updateEditor, 10 | persistedState, 11 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 12 | ) 13 | store.subscribe(() => { 14 | localStorage.setItem('brewCalcState', JSON.stringify(store.getState())) 15 | }) 16 | 17 | class App extends React.Component { 18 | render() { 19 | return ( 20 | 21 |
22 | 23 |
24 |
25 | ) 26 | } 27 | } 28 | 29 | export default App 30 | -------------------------------------------------------------------------------- /app/src/components/CrossUnitsInput.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { convert } from "brewcalc"; 4 | 5 | const unitLabelMap = { 6 | sg: "SG", 7 | plato: "°P", 8 | F: "°F", 9 | C: "°C", 10 | }; 11 | 12 | const stringifyMeasurable = (measurable, precision) => { 13 | const unit = unitLabelMap[measurable.unit] || measurable.unit; 14 | const value = 15 | precision != null ? +measurable.value.toFixed(precision) : measurable.value; 16 | return `${value} ${unit}`; 17 | }; 18 | 19 | const convertMeasurable = (measurable, unit, precision) => { 20 | return { 21 | value: convert(measurable.value, measurable.unit, unit, precision), 22 | unit: unit, 23 | }; 24 | }; 25 | 26 | export const printMeasurable = (measurable, convertTo, precision = 0) => { 27 | if (measurable == null) { 28 | return ""; 29 | } 30 | if (convertTo == null) { 31 | return stringifyMeasurable(measurable, precision); 32 | } 33 | return stringifyMeasurable( 34 | convertMeasurable(measurable, convertTo, precision) 35 | ); 36 | }; 37 | 38 | const CrossUnitsInput = ({ 39 | name, 40 | description, 41 | measurable, 42 | units = [], 43 | precision = 2, 44 | }) => { 45 | if (measurable == null || measurable.value == null) { 46 | return null; 47 | } 48 | 49 | const [primary, secondary] = units; 50 | 51 | return ( 52 |
53 | {name && {name} } 54 | {printMeasurable(measurable, primary, precision)} 55 | {secondary && ` (${printMeasurable(measurable, secondary, precision)})`} 56 |
57 | ); 58 | }; 59 | 60 | CrossUnitsInput.propTypes = { 61 | name: PropTypes.string, 62 | description: PropTypes.string, 63 | measurable: PropTypes.shape({ 64 | value: PropTypes.number, 65 | unit: PropTypes.string, 66 | }), 67 | units: PropTypes.arrayOf(PropTypes.string), 68 | precision: PropTypes.number, 69 | }; 70 | 71 | export default CrossUnitsInput; 72 | -------------------------------------------------------------------------------- /app/src/components/CrossUnitsInput.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import renderer from "react-test-renderer"; 3 | import CrossUnitsInput from "./CrossUnitsInput"; 4 | 5 | test("renders without crashing", () => { 6 | const tree = renderer 7 | .create( 8 | 14 | ) 15 | .toJSON(); 16 | expect(tree).toMatchSnapshot(); 17 | }); 18 | 19 | test("renders without crashing on US units", () => { 20 | const tree = renderer 21 | .create( 22 | 28 | ) 29 | .toJSON(); 30 | expect(tree).toMatchSnapshot(); 31 | }); 32 | -------------------------------------------------------------------------------- /app/src/components/Ingredients.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Table } from "react-bootstrap"; 3 | import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput"; 4 | 5 | const Ingredients = ({ recipe }) => { 6 | const { fermentable_additions, hop_additions, culture_additions } = 7 | recipe.ingredients; 8 | return ( 9 | 10 | Ingredients 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {fermentable_additions.map((i, index) => ( 22 | 23 | 26 | 29 | 30 | 31 | ))} 32 | {hop_additions.map((i, index) => ( 33 | 34 | 37 | 41 | 42 | 43 | ))} 44 | {culture_additions.map((i, index) => ( 45 | 46 | 47 | 51 | 52 | 53 | ))} 54 | 55 |
AmountNameType
24 | 25 | 27 | {i.name} ({printMeasurable(i.color, "SRM", 0)}) 28 | {i.type}
35 | 36 | 38 | {i.name} ({printMeasurable(i.alpha_acid)} Alpha, Boil time{" "} 39 | {printMeasurable(i.timing.time)}) 40 | Hop
{printMeasurable(i.amount, null, 2)} 48 | {i.name} (Attenuation {printMeasurable(i.attenuation, null, 2)}, 49 | Form {i.form}) 50 | Yeast
56 |
57 | ); 58 | }; 59 | 60 | export default Ingredients; 61 | -------------------------------------------------------------------------------- /app/src/components/MashSteps.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Table } from "react-bootstrap"; 3 | import { 4 | recalculateMashSteps, 5 | calcBoilVolumes, 6 | calcMashVolumes, 7 | calcMashGrainWeight, 8 | } from "brewcalc"; 9 | import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput"; 10 | 11 | //TODO: Add BIAB 12 | const MashStepDescription = ({ step, spargeVolume }) => { 13 | switch (step.type) { 14 | case "decoction": 15 | return ( 16 | 17 | Decoct  18 | 19 |  of mash and boil it 20 | 21 | ); 22 | case "temperature": 23 | if (step.infuseAmount > 0) 24 | return ( 25 | 26 | Add  27 | 28 |  of water and heat to  29 | 34 | 35 | ); 36 | else 37 | return ( 38 | 39 | Heat to  40 | 45 |  over {printMeasurable(step.step_time)} min 46 | 47 | ); 48 | case "sparge": 49 | return ( 50 | 51 | Fly sparge with  52 | 53 |  water at  54 | 59 | 60 | ); 61 | case "infusion": 62 | default: 63 | return ( 64 | 65 | Add  66 | 67 |  of water at  68 | 73 | 74 | ); 75 | } 76 | }; 77 | 78 | const MashSteps = ({ recipe, equipment }) => { 79 | const mashGrainWeight = calcMashGrainWeight( 80 | recipe.ingredients.fermentable_additions 81 | ); 82 | const recalculatedMashSteps = recalculateMashSteps( 83 | recipe.mash.mash_steps, 84 | recipe.mash.grain_temperature, 85 | mashGrainWeight 86 | ); 87 | 88 | const { pre_boil_size } = calcBoilVolumes( 89 | recipe.batch_size, 90 | recipe.boil, 91 | equipment 92 | ); 93 | 94 | const { sparge_volume } = calcMashVolumes( 95 | pre_boil_size, 96 | recalculatedMashSteps, 97 | mashGrainWeight, 98 | equipment 99 | ); 100 | 101 | return ( 102 | 103 | Mash Steps 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | {recalculatedMashSteps.map((step, index) => ( 115 | 116 | 117 | 118 | 125 | 132 | 133 | ))} 134 | 135 |
NameDescriptionStep TemperatureStep Time
{step.name} 119 | 124 | 126 | 131 |
136 |
137 | ); 138 | }; 139 | 140 | export default MashSteps; 141 | -------------------------------------------------------------------------------- /app/src/components/Recipe.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import RecipeSpecs from './RecipeSpecs' 3 | import Ingredients from './Ingredients' 4 | import Stats from './Stats' 5 | import MashSteps from './MashSteps' 6 | 7 | const Recipe = ({ recipe, equipment }) => 8 | recipe !== undefined && 9 |
10 | 11 | 12 | 13 | {equipment !== null && 14 | 15 | } 16 | 17 | 18 |
19 | export default Recipe 20 | -------------------------------------------------------------------------------- /app/src/components/Recipe.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Recipe from "./Recipe"; 3 | import { recipe } from "../data/recipe"; 4 | import { equipment } from "../data/equipment"; 5 | import renderer from "react-test-renderer"; 6 | 7 | test("renders without crashing", () => { 8 | const tree = renderer 9 | .create() 10 | .toJSON(); 11 | expect(tree).toMatchSnapshot(); 12 | }); 13 | 14 | test("renders without crashing with null equipment", () => { 15 | const tree = renderer 16 | .create() 17 | .toJSON(); 18 | expect(tree).toMatchSnapshot(); 19 | }); 20 | -------------------------------------------------------------------------------- /app/src/components/RecipeSpecs.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Col, Row } from "react-bootstrap"; 3 | 4 | import CrossUnitsInput from "./CrossUnitsInput"; 5 | 6 | const RecipeSpecs = ({ recipe, equipment }) => { 7 | const { 8 | name, 9 | author, 10 | type, 11 | batch_size, 12 | boil: { boil_time, pre_boil_size }, 13 | efficiency, 14 | } = recipe; 15 | return ( 16 | 17 | Recipe Specs and Equipment 18 | 19 | 20 | 21 |
22 |
23 | {" "} 24 | Name {name}{" "} 25 |
26 |
27 | {" "} 28 | Brewer {author}{" "} 29 |
30 |
31 | {" "} 32 | Type {type}{" "} 33 |
34 | 40 | 46 | 51 | 56 |
57 | 58 | 59 | {equipment === null ? ( 60 |
Equipment is not set
61 | ) : ( 62 |
63 |
64 | {" "} 65 | Equipment Name {equipment.name}{" "} 66 |
67 | 73 | 79 | 86 | 92 | 99 | 106 |
107 | )} 108 | 109 |
110 |
111 |
112 | ); 113 | }; 114 | export default RecipeSpecs; 115 | -------------------------------------------------------------------------------- /app/src/components/RecipeSpecs.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import RecipeSpecs from './RecipeSpecs' 3 | import renderer from 'react-test-renderer' 4 | import { recipe } from '../data/recipe' 5 | import { equipment } from '../data/equipment' 6 | 7 | it('renders correctly', () => { 8 | const tree = renderer 9 | .create( 10 | 11 | ) 12 | .toJSON() 13 | expect(tree).toMatchSnapshot() 14 | }) 15 | 16 | it('renders correctly if equipment is null', () => { 17 | const tree = renderer 18 | .create( 19 | 20 | ) 21 | .toJSON() 22 | expect(tree).toMatchSnapshot() 23 | }) -------------------------------------------------------------------------------- /app/src/components/Stats.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Row, Col } from "react-bootstrap"; 3 | import CrossUnitsInput, { printMeasurable } from "./CrossUnitsInput"; 4 | 5 | import { 6 | calcOriginalGravity, 7 | calcFinalGravity, 8 | calcBoilGravity, 9 | calcColor, 10 | calcABV, 11 | calcCalories, 12 | srmToCss, 13 | bitternessIbuTinseth, 14 | convert, 15 | calcBoilVolumes, 16 | } from "brewcalc"; 17 | 18 | const Stats = ({ recipe, equipment }) => { 19 | const { batch_size, boil, ingredients, efficiency } = recipe; 20 | 21 | const { fermentable_additions, hop_additions, culture_additions } = 22 | ingredients; 23 | 24 | const og = calcOriginalGravity(batch_size, fermentable_additions, efficiency); 25 | 26 | const fg = calcFinalGravity( 27 | batch_size, 28 | fermentable_additions, 29 | efficiency, 30 | culture_additions 31 | ); 32 | 33 | const { pre_boil_size } = calcBoilVolumes(batch_size, boil, equipment); 34 | 35 | const boilGravity = calcBoilGravity(batch_size, pre_boil_size, og); 36 | 37 | const ibu = bitternessIbuTinseth(hop_additions, boilGravity, batch_size); 38 | 39 | const color = calcColor(fermentable_additions, batch_size); 40 | 41 | const abv = calcABV(og, fg); 42 | const calories = calcCalories(og.value, fg.value); 43 | const caloriesInOneL = calories / (12 * convert(1, "floz", "l")); 44 | 45 | return ( 46 | 47 | Gravity, Alcohol Content and Color 48 | 49 | 50 | 51 |
52 | 59 | 66 |
67 | Bitterness (IBUs): 68 | {printMeasurable(ibu, null, 0)} by Tinseth formula 69 |
70 | 77 |
78 | Alcohol by volume : 79 | {printMeasurable(abv)} 80 |
81 |
82 | Calories: 83 | {caloriesInOneL.toFixed(0)} per one L 84 |
85 |
86 | 87 | 88 |
94 | 95 | 96 | 97 | 98 | ); 99 | }; 100 | 101 | export default Stats; 102 | -------------------------------------------------------------------------------- /app/src/components/__snapshots__/CrossUnitsInput.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 |
7 | 8 | Boil Size 9 | 10 | 11 | 20 l 12 | (5.28 gal) 13 |
14 | `; 15 | 16 | exports[`renders without crashing on US units 1`] = ` 17 |
20 | 21 | Boil Size 22 | 23 | 24 | 18.93 l 25 | (5 gal) 26 |
27 | `; 28 | -------------------------------------------------------------------------------- /app/src/components/__snapshots__/Recipe.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders without crashing 1`] = ` 4 |
5 |
8 |
11 | Recipe Specs and Equipment 12 |
13 |
16 |
19 |
22 |
23 |
26 | 27 | 28 | Name 29 | 30 | 31 | Aussie Ale 32 | 33 |
34 |
37 | 38 | 39 | Brewer 40 | 41 | 42 | Steve Nicholls 43 | 44 |
45 |
48 | 49 | 50 | Type 51 | 52 | 53 | All Grain 54 | 55 |
56 |
59 | 60 | Batch Size 61 | 62 | 63 | 23.02 l 64 | (6.08 gal) 65 |
66 |
69 | 70 | Boil Size 71 | 72 | 73 | 37.12 l 74 | (9.81 gal) 75 |
76 |
79 | 80 | Boil Time 81 | 82 | 83 | 90 min 84 |
85 |
88 | 89 | Efficiency 90 | 91 | 92 | 68 % 93 |
94 |
95 |
96 |
99 |
100 |
103 | 104 | 105 | Equipment Name 106 | 107 | 108 | Pot (13 Gal/50 L) - BIAB 109 | 110 |
111 |
114 | 115 | Batch Size 116 | 117 | 118 | 23.02 l 119 | (6.08 gal) 120 |
121 |
124 | 125 | Boil Size 126 | 127 | 128 | 37.12 l 129 | (9.81 gal) 130 |
131 |
134 | 135 | Evap Rate 136 | 137 | 138 | 5.36 l 139 |
140 |
143 | 144 | Trub Chiller Loss 145 | 146 | 147 | 3.82 l 148 | (1.01 gal) 149 |
150 |
151 |
152 |
153 |
154 |
155 |
158 |
161 | Ingredients 162 |
163 | 166 | 167 | 168 | 171 | 174 | 177 | 178 | 179 | 180 | 181 | 187 | 193 | 202 | 203 | 204 | 210 | 216 | 225 | 226 | 227 | 233 | 239 | 248 | 249 | 250 | 256 | 262 | 271 | 272 | 273 | 279 | 285 | 294 | 295 | 296 | 302 | 311 | 314 | 315 | 316 | 322 | 331 | 334 | 335 | 336 | 342 | 351 | 354 | 355 | 356 | 362 | 371 | 374 | 375 | 376 | 379 | 387 | 390 | 391 | 392 |
169 | Amount 170 | 172 | Name 173 | 175 | Type 176 |
182 |
183 | 4.5 kg 184 | (9.91 lb) 185 |
186 |
188 | Pale Malt (2 Row) UK 189 | ( 190 | 3 SRM 191 | ) 192 | 200 | grain 201 |
205 |
206 | 0.52 kg 207 | (1.14 lb) 208 |
209 |
211 | Munich Malt - 20L 212 | ( 213 | 20 SRM 214 | ) 215 | 223 | grain 224 |
228 |
229 | 0.2 kg 230 | (0.44 lb) 231 |
232 |
234 | Caramel/Crystal Malt - 20L 235 | ( 236 | 20 SRM 237 | ) 238 | 246 | grain 247 |
251 |
252 | 0.05 kg 253 | (0.1 lb) 254 |
255 |
257 | Roasted Barley 258 | ( 259 | 300 SRM 260 | ) 261 | 269 | grain 270 |
274 |
275 | 0.26 kg 276 | (0.58 lb) 277 |
278 |
280 | Cane (Beet) Sugar 281 | ( 282 | 0 SRM 283 | ) 284 | 292 | sugar 293 |
297 |
298 | 5.21 g 299 | (0.18 oz) 300 |
301 |
303 | Pride of Ringwood 304 | ( 305 | 10 % 306 | Alpha, Boil time 307 | 308 | 60 min 309 | ) 310 | 312 | Hop 313 |
317 |
318 | 5.21 g 319 | (0.18 oz) 320 |
321 |
323 | Pride of Ringwood 324 | ( 325 | 10 % 326 | Alpha, Boil time 327 | 328 | 45 min 329 | ) 330 | 332 | Hop 333 |
337 |
338 | 31.23 g 339 | (1.1 oz) 340 |
341 |
343 | Pride of Ringwood 344 | ( 345 | 10 % 346 | Alpha, Boil time 347 | 348 | 15 min 349 | ) 350 | 352 | Hop 353 |
357 |
358 | 15.68 g 359 | (0.55 oz) 360 |
361 |
363 | Pride of Ringwood 364 | ( 365 | 10 % 366 | Alpha, Boil time 367 | 368 | 0 min 369 | ) 370 | 372 | Hop 373 |
377 | 0.13 ml 378 | 380 | American Ale 381 | (Attenuation 382 | 75 % 383 | , Form 384 | liquid 385 | ) 386 | 388 | Yeast 389 |
393 |
394 |
397 |
400 | Mash Steps 401 |
402 | 405 | 406 | 407 | 410 | 413 | 416 | 419 | 420 | 421 | 422 | 423 | 426 | 444 | 450 | 455 | 456 | 457 | 460 | 478 | 484 | 489 | 490 | 491 | 494 | 512 | 518 | 523 | 524 | 525 |
408 | Name 409 | 411 | Description 412 | 414 | Step Temperature 415 | 417 | Step Time 418 |
424 | Mash In 425 | 433 | Add  434 |
435 | 13.16 l 436 | (3.48 gal) 437 |
438 |  of water at  439 |
440 | 67 °C 441 | (152 °F) 442 |
443 |
445 |
446 | 67 °C 447 | (152 °F) 448 |
449 |
451 |
452 | 60 min 453 |
454 |
458 | Mash Out 459 | 467 | Add  468 |
469 | 5.51 l 470 | (1.45 gal) 471 |
472 |  of water at  473 |
474 | 76 °C 475 | (168 °F) 476 |
477 |
479 |
480 | 76 °C 481 | (168 °F) 482 |
483 |
485 |
486 | 10 min 487 |
488 |
492 | Sparge 493 | 501 | Fly sparge with  502 |
503 | 10.55 l 504 | (2.79 gal) 505 |
506 |  water at  507 |
508 | 76 °C 509 | (168 °F) 510 |
511 |
513 |
514 | 76 °C 515 | (168 °F) 516 |
517 |
519 |
520 | 10 min 521 |
522 |
526 |
527 |
530 |
533 | Gravity, Alcohol Content and Color 534 |
535 |
538 |
541 |
544 |
545 |
548 | 549 | Original Gravity 550 | 551 | 552 | 1.044 SG 553 | (10.964 °P) 554 |
555 |
558 | 559 | Final Gravity 560 | 561 | 562 | 1.011 SG 563 | (2.817 °P) 564 |
565 |
568 | 569 | Bitterness (IBUs): 570 | 571 | 28 IBUs 572 | by Tinseth formula 573 |
574 |
577 | 578 | Color 579 | 580 | 581 | 8 SRM 582 | (16 EBC) 583 |
584 |
587 | 588 | Alcohol by volume : 589 | 590 | 4 % 591 |
592 |
595 | 596 | Calories: 597 | 598 | 404 599 | per one L 600 |
601 |
602 |
603 |
606 |
614 |
615 |
616 |
617 |
618 |
619 | `; 620 | 621 | exports[`renders without crashing with null equipment 1`] = ` 622 |
623 |
626 |
629 | Recipe Specs and Equipment 630 |
631 |
634 |
637 |
640 |
641 |
644 | 645 | 646 | Name 647 | 648 | 649 | Aussie Ale 650 | 651 |
652 |
655 | 656 | 657 | Brewer 658 | 659 | 660 | Steve Nicholls 661 | 662 |
663 |
666 | 667 | 668 | Type 669 | 670 | 671 | All Grain 672 | 673 |
674 |
677 | 678 | Batch Size 679 | 680 | 681 | 23.02 l 682 | (6.08 gal) 683 |
684 |
687 | 688 | Boil Size 689 | 690 | 691 | 37.12 l 692 | (9.81 gal) 693 |
694 |
697 | 698 | Boil Time 699 | 700 | 701 | 90 min 702 |
703 |
706 | 707 | Efficiency 708 | 709 | 710 | 68 % 711 |
712 |
713 |
714 |
717 |
718 | Equipment is not set 719 |
720 |
721 |
722 |
723 |
724 |
727 |
730 | Ingredients 731 |
732 | 735 | 736 | 737 | 740 | 743 | 746 | 747 | 748 | 749 | 750 | 756 | 762 | 771 | 772 | 773 | 779 | 785 | 794 | 795 | 796 | 802 | 808 | 817 | 818 | 819 | 825 | 831 | 840 | 841 | 842 | 848 | 854 | 863 | 864 | 865 | 871 | 880 | 883 | 884 | 885 | 891 | 900 | 903 | 904 | 905 | 911 | 920 | 923 | 924 | 925 | 931 | 940 | 943 | 944 | 945 | 948 | 956 | 959 | 960 | 961 |
738 | Amount 739 | 741 | Name 742 | 744 | Type 745 |
751 |
752 | 4.5 kg 753 | (9.91 lb) 754 |
755 |
757 | Pale Malt (2 Row) UK 758 | ( 759 | 3 SRM 760 | ) 761 | 769 | grain 770 |
774 |
775 | 0.52 kg 776 | (1.14 lb) 777 |
778 |
780 | Munich Malt - 20L 781 | ( 782 | 20 SRM 783 | ) 784 | 792 | grain 793 |
797 |
798 | 0.2 kg 799 | (0.44 lb) 800 |
801 |
803 | Caramel/Crystal Malt - 20L 804 | ( 805 | 20 SRM 806 | ) 807 | 815 | grain 816 |
820 |
821 | 0.05 kg 822 | (0.1 lb) 823 |
824 |
826 | Roasted Barley 827 | ( 828 | 300 SRM 829 | ) 830 | 838 | grain 839 |
843 |
844 | 0.26 kg 845 | (0.58 lb) 846 |
847 |
849 | Cane (Beet) Sugar 850 | ( 851 | 0 SRM 852 | ) 853 | 861 | sugar 862 |
866 |
867 | 5.21 g 868 | (0.18 oz) 869 |
870 |
872 | Pride of Ringwood 873 | ( 874 | 10 % 875 | Alpha, Boil time 876 | 877 | 60 min 878 | ) 879 | 881 | Hop 882 |
886 |
887 | 5.21 g 888 | (0.18 oz) 889 |
890 |
892 | Pride of Ringwood 893 | ( 894 | 10 % 895 | Alpha, Boil time 896 | 897 | 45 min 898 | ) 899 | 901 | Hop 902 |
906 |
907 | 31.23 g 908 | (1.1 oz) 909 |
910 |
912 | Pride of Ringwood 913 | ( 914 | 10 % 915 | Alpha, Boil time 916 | 917 | 15 min 918 | ) 919 | 921 | Hop 922 |
926 |
927 | 15.68 g 928 | (0.55 oz) 929 |
930 |
932 | Pride of Ringwood 933 | ( 934 | 10 % 935 | Alpha, Boil time 936 | 937 | 0 min 938 | ) 939 | 941 | Hop 942 |
946 | 0.13 ml 947 | 949 | American Ale 950 | (Attenuation 951 | 75 % 952 | , Form 953 | liquid 954 | ) 955 | 957 | Yeast 958 |
962 |
963 |
966 |
969 | Gravity, Alcohol Content and Color 970 |
971 |
974 |
977 |
980 |
981 |
984 | 985 | Original Gravity 986 | 987 | 988 | 1.044 SG 989 | (10.964 °P) 990 |
991 |
994 | 995 | Final Gravity 996 | 997 | 998 | 1.011 SG 999 | (2.817 °P) 1000 |
1001 |
1004 | 1005 | Bitterness (IBUs): 1006 | 1007 | 28 IBUs 1008 | by Tinseth formula 1009 |
1010 |
1013 | 1014 | Color 1015 | 1016 | 1017 | 8 SRM 1018 | (16 EBC) 1019 |
1020 |
1023 | 1024 | Alcohol by volume : 1025 | 1026 | 4 % 1027 |
1028 |
1031 | 1032 | Calories: 1033 | 1034 | 404 1035 | per one L 1036 |
1037 |
1038 |
1039 |
1042 |
1050 |
1051 |
1052 |
1053 |
1054 |
1055 | `; 1056 | -------------------------------------------------------------------------------- /app/src/components/__snapshots__/RecipeSpecs.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 |
7 |
10 | Recipe Specs and Equipment 11 |
12 |
15 |
18 |
21 |
22 |
25 | 26 | 27 | Name 28 | 29 | 30 | Aussie Ale 31 | 32 |
33 |
36 | 37 | 38 | Brewer 39 | 40 | 41 | Steve Nicholls 42 | 43 |
44 |
47 | 48 | 49 | Type 50 | 51 | 52 | All Grain 53 | 54 |
55 |
58 | 59 | Batch Size 60 | 61 | 62 | 23.02 l 63 | (6.08 gal) 64 |
65 |
68 | 69 | Boil Size 70 | 71 | 72 | 37.12 l 73 | (9.81 gal) 74 |
75 |
78 | 79 | Boil Time 80 | 81 | 82 | 90 min 83 |
84 |
87 | 88 | Efficiency 89 | 90 | 91 | 68 % 92 |
93 |
94 |
95 |
98 |
99 |
102 | 103 | 104 | Equipment Name 105 | 106 | 107 | Pot (13 Gal/50 L) - BIAB 108 | 109 |
110 |
113 | 114 | Batch Size 115 | 116 | 117 | 23.02 l 118 | (6.08 gal) 119 |
120 |
123 | 124 | Boil Size 125 | 126 | 127 | 37.12 l 128 | (9.81 gal) 129 |
130 |
133 | 134 | Evap Rate 135 | 136 | 137 | 5.36 l 138 |
139 |
142 | 143 | Trub Chiller Loss 144 | 145 | 146 | 3.82 l 147 | (1.01 gal) 148 |
149 |
150 |
151 |
152 |
153 |
154 | `; 155 | 156 | exports[`renders correctly if equipment is null 1`] = ` 157 |
160 |
163 | Recipe Specs and Equipment 164 |
165 |
168 |
171 |
174 |
175 |
178 | 179 | 180 | Name 181 | 182 | 183 | Aussie Ale 184 | 185 |
186 |
189 | 190 | 191 | Brewer 192 | 193 | 194 | Steve Nicholls 195 | 196 |
197 |
200 | 201 | 202 | Type 203 | 204 | 205 | All Grain 206 | 207 |
208 |
211 | 212 | Batch Size 213 | 214 | 215 | 23.02 l 216 | (6.08 gal) 217 |
218 |
221 | 222 | Boil Size 223 | 224 | 225 | 37.12 l 226 | (9.81 gal) 227 |
228 |
231 | 232 | Boil Time 233 | 234 | 235 | 90 min 236 |
237 |
240 | 241 | Efficiency 242 | 243 | 244 | 68 % 245 |
246 |
247 |
248 |
251 |
252 | Equipment is not set 253 |
254 |
255 |
256 |
257 |
258 | `; 259 | -------------------------------------------------------------------------------- /app/src/containers/EditorContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Accordion } from "react-bootstrap"; 3 | import { connect } from "react-redux"; 4 | 5 | import AceEditor from "react-ace"; 6 | import "ace-builds/src-noconflict/theme-github"; 7 | import "ace-builds/src-noconflict/mode-json"; 8 | 9 | //TODO: 10 | //https://github.com/securingsincity/react-ace/issues/240 11 | 12 | const EditorContainer = ({ editorState, onContentUpdate }) => ( 13 | 14 | 15 | ⇨ Expand and edit recipe and equipment JSON 16 | 17 | 18 | 27 | 28 | 29 | ); 30 | 31 | const mapStateToProps = ({ editorState }) => ({ editorState }); 32 | 33 | const mapDispatchToProps = (dispatch) => ({ 34 | onContentUpdate: (editorState) => { 35 | dispatch({ 36 | type: "UPDATE_EDITOR_STATE", 37 | payload: editorState, 38 | }); 39 | }, 40 | }); 41 | 42 | export default connect(mapStateToProps, mapDispatchToProps)(EditorContainer); 43 | -------------------------------------------------------------------------------- /app/src/containers/ImportArea.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FormGroup, FormControl, Card, Row, Col } from "react-bootstrap"; 3 | import importFromBeerXml from "@beerjson/beerjson/js/beerxml-to-beerjson"; 4 | import { equipment } from "../data/equipment"; 5 | import { connect } from "react-redux"; 6 | 7 | const ImportArea = ({ editorState, onReloadEditorState }) => { 8 | const onXmlLoaded = (e) => { 9 | const reader = new FileReader(); 10 | const file = e.target.files[0]; 11 | reader.readAsText(file); 12 | reader.onloadend = function () { 13 | try { 14 | const result = file.type.includes("xml") 15 | ? importFromBeerXml(reader.result) 16 | : reader.result; 17 | 18 | const { beerjson } = JSON.parse(result); 19 | // TODO: equipment 20 | onReloadEditorState( 21 | JSON.stringify( 22 | { 23 | recipe: beerjson.recipes[0], 24 | equipment: equipment /*beerjson.equipment*/, 25 | }, 26 | null, 27 | 4 28 | ) 29 | ); 30 | } catch (err) { 31 | console.warn(err); 32 | alert("Can't import from BeerXML, see console for the details"); 33 | } 34 | }; 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | Upload BeerXML / BeerJSON file 42 | 43 | 44 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | brewcalc 58 | 59 |
60 | A modern (ES6) functional JavaScript library for brewing 61 | calculations. 62 |
63 | 64 | brewcalc lib on the GitHub (MIT license) 65 | 66 |
67 |
68 | 69 |
70 | ); 71 | }; 72 | 73 | const mapStateToProps = ({ editorState }) => ({ editorState }); 74 | 75 | const mapDispatchToProps = (dispatch) => ({ 76 | onReloadEditorState: (editorState) => { 77 | dispatch({ 78 | type: "UPDATE_EDITOR_STATE", 79 | payload: editorState, 80 | }); 81 | }, 82 | }); 83 | 84 | export default connect(mapStateToProps, mapDispatchToProps)(ImportArea); 85 | -------------------------------------------------------------------------------- /app/src/containers/PageRecipe.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container } from "react-bootstrap"; 3 | import Recipe from "../components/Recipe"; 4 | import ImportArea from "./ImportArea"; 5 | import EditorContainer from "./EditorContainer"; 6 | 7 | import { connect } from "react-redux"; 8 | 9 | const PageRecipe = ({ editorState }) => { 10 | const tryParse = (editorState) => { 11 | try { 12 | return JSON.parse(editorState); 13 | } catch (error) { 14 | return null; 15 | } 16 | }; 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | const mapStateToProps = ({ editorState }) => ({ editorState }); 27 | 28 | export default connect(mapStateToProps)(PageRecipe); 29 | -------------------------------------------------------------------------------- /app/src/data/equipment.js: -------------------------------------------------------------------------------- 1 | export const equipment = { 2 | name: "Pot (13 Gal/50 L) - BIAB", 3 | equipment_items: { 4 | brew_kettle: { 5 | name: "Pot (13 Gal/50 L) - BIAB", 6 | form: "Brew Kettle", 7 | maximum_volume: { 8 | value: 50, 9 | unit: "l", 10 | }, 11 | loss: { 12 | value: 3.8232879, 13 | unit: "l", 14 | }, 15 | boil_rate_per_hour: { 16 | value: 5.364, 17 | unit: "l", 18 | }, 19 | }, 20 | }, 21 | 22 | /*boilSize: 36.019978, 23 | batchSize: 23.0154363, 24 | tunVolume: 50.0055779, 25 | tunWeight: 6.9989246, 26 | tunSpecificHeat: 0.12, 27 | topUpWater: "0.0000000", 28 | trubChillerLoss: 3.8232879, 29 | evapRate: 0.14923151, 30 | boilTime: "90.0000000", 31 | calcBoilVolume: "TRUE", 32 | lauterDeadspace: 0, 33 | topUpKettle: 0, 34 | hopUtilization: "100.0000000", 35 | coolingLossPct: 0.04, 36 | efficiency: 0.68, 37 | BIAB: false, 38 | 39 | 40 | tunWeight: "144.0000000", 41 | tunSpecificHeat: "0.3000000", 42 | displayTunTemp: "22.2 C", 43 | displayTunWeight: "4.08 kg", 44 | 45 | */ 46 | }; 47 | -------------------------------------------------------------------------------- /app/src/data/recipe.js: -------------------------------------------------------------------------------- 1 | export const recipe = { 2 | name: "Aussie Ale", 3 | version: "1", 4 | type: "All Grain", 5 | author: "Steve Nicholls", 6 | asstBrewer: "", 7 | batch_size: { 8 | value: 23.02, 9 | unit: "l", 10 | }, 11 | boil: { 12 | pre_boil_size: { 13 | value: 37.1203164, 14 | unit: "l", 15 | }, 16 | boil_time: { value: 90, unit: "min" }, 17 | }, 18 | efficiency: { brewhouse: { value: 68, unit: "%" } }, 19 | ingredients: { 20 | fermentable_additions: [ 21 | { 22 | name: "Pale Malt (2 Row) UK", 23 | type: "grain", 24 | amount: { value: 4.4969891, unit: "kg" }, 25 | yield: { 26 | fine_coarse_difference: { value: 1.5, unit: "%" }, 27 | potential: { value: 1.029992, unit: "sg" }, 28 | }, 29 | color: { value: 4.925, unit: "EBC" }, 30 | origin: "United Kingdom", 31 | supplier: "", 32 | notes: 33 | "Base malt for all English beer styles\r\nLower diastatic power than American 2 Row Pale Malt", 34 | moisture: { 35 | value: 4, 36 | unit: "%", 37 | }, 38 | diastatic_power: { 39 | value: 45, 40 | unit: "Lintner", 41 | }, 42 | protein: { 43 | value: 10.1, 44 | unit: "%", 45 | }, 46 | max_in_batch: { 47 | value: 100, 48 | unit: "%", 49 | }, 50 | recommend_mash: false, 51 | }, 52 | { 53 | name: "Munich Malt - 20L", 54 | type: "grain", 55 | amount: { value: 518.8834, unit: "g" }, 56 | yield: { 57 | fine_coarse_difference: { value: 2.8, unit: "%" }, 58 | potential: { value: 1.0345, unit: "sg" }, 59 | }, 60 | color: { value: 39.4, unit: "EBC" }, 61 | origin: "US", 62 | supplier: "", 63 | notes: 64 | "Malty-sweet flavor characteristic and adds a orange to deep orange color to the beer.\r\nDoes not contribute signficantly to body or head retention.\r\nUse for: Bock, Porter, Marzen, Oktoberfest beers", 65 | moisture: { 66 | value: 5, 67 | unit: "%", 68 | }, 69 | diastatic_power: { 70 | value: 25, 71 | unit: "Lintner", 72 | }, 73 | protein: { 74 | value: 13.5, 75 | unit: "%", 76 | }, 77 | max_in_batch: { 78 | value: 80, 79 | unit: "%", 80 | }, 81 | recommend_mash: true, 82 | }, 83 | { 84 | name: "Caramel/Crystal Malt - 20L", 85 | type: "grain", 86 | amount: { value: 0.201788, unit: "kg" }, 87 | yield: { 88 | fine_coarse_difference: { value: 1.5, unit: "%" }, 89 | potential: { value: 1.0345, unit: "sg" }, 90 | }, 91 | color: { value: 39.4, unit: "EBC" }, 92 | origin: "US", 93 | supplier: "", 94 | notes: 95 | "Adds body, color and improves head retention.\r\nAlso called "Crystal" malt.", 96 | moisture: { 97 | value: 4, 98 | unit: "%", 99 | }, 100 | diastatic_power: { 101 | value: 0, 102 | unit: "Lintner", 103 | }, 104 | protein: { 105 | value: 13.2, 106 | unit: "%", 107 | }, 108 | max_in_batch: { 109 | value: 20, 110 | unit: "%", 111 | }, 112 | recommend_mash: false, 113 | }, 114 | { 115 | name: "Roasted Barley", 116 | type: "grain", 117 | amount: { value: 0.046123, unit: "kg" }, 118 | yield: { 119 | fine_coarse_difference: { value: 1.5, unit: "%" }, 120 | potential: { value: 1.0253, unit: "sg" }, 121 | }, 122 | color: { value: 591.0, unit: "EBC" }, 123 | origin: "US", 124 | supplier: "", 125 | notes: 126 | "Roasted at high temperature to create a burnt, grainy, coffee like flavor.\r\nImparts a red to deep brown color to beer, and very strong roasted flavor.\r\nUse 2-4% in Brown ales to add a nutty flavor, or 3-10% in Porters and Stouts for coffee flavor.", 127 | moisture: { 128 | value: 5, 129 | unit: "%", 130 | }, 131 | diastatic_power: { 132 | value: 0, 133 | unit: "Lintner", 134 | }, 135 | protein: { 136 | value: 13.2, 137 | unit: "%", 138 | }, 139 | max_in_batch: { 140 | value: 10, 141 | unit: "%", 142 | }, 143 | recommend_mash: false, 144 | }, 145 | { 146 | name: "Cane (Beet) Sugar", 147 | type: "sugar", 148 | amount: { value: 0.2613635, unit: "kg" }, 149 | yield: { 150 | fine_coarse_difference: { value: 1.5, unit: "%" }, 151 | potential: { value: 1.046, unit: "sg" }, 152 | }, 153 | color: { value: 0, unit: "EBC" }, 154 | origin: "US", 155 | supplier: "", 156 | notes: 157 | "Common household baking sugar.\r\nLightens flavor and body of beer.\r\nCan contribute a cider-like flavor to the beer if not cold-fermented or used in large quantities.", 158 | moisture: { 159 | value: 4, 160 | unit: "%", 161 | }, 162 | diastatic_power: { 163 | value: 120, 164 | unit: "Lintner", 165 | }, 166 | protein: { 167 | value: 11.7, 168 | unit: "%", 169 | }, 170 | max_in_batch: { 171 | value: 7, 172 | unit: "%", 173 | }, 174 | recommend_mash: false, 175 | }, 176 | ], 177 | hop_additions: [ 178 | { 179 | name: "Pride of Ringwood", 180 | origin: "Australia", 181 | alpha_acid: { 182 | value: 10, 183 | unit: "%", 184 | }, 185 | amount: { value: 5.2055, unit: "g" }, 186 | timing: { 187 | // TODO: first wort??? 188 | use: "add_to_boil", 189 | time: { value: 60, unit: "min" }, 190 | }, 191 | notes: 192 | "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena", 193 | type: "Both", 194 | form: "leaf", 195 | beta_acid: { 196 | value: 5.8, 197 | unit: "%", 198 | }, 199 | percent_lost: { 200 | value: 45, 201 | unit: "%", 202 | }, 203 | }, 204 | { 205 | name: "Pride of Ringwood", 206 | origin: "Australia", 207 | alpha_acid: { 208 | value: 10, 209 | unit: "%", 210 | }, 211 | amount: { value: 5.2055, unit: "g" }, 212 | timing: { 213 | use: "add_to_boil", 214 | time: { value: 45, unit: "min" }, 215 | }, 216 | notes: 217 | "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena", 218 | type: "bittering", 219 | form: "pellet", 220 | beta_acid: { 221 | value: 5.8, 222 | unit: "%", 223 | }, 224 | percent_lost: { 225 | value: 45, 226 | unit: "%", 227 | }, 228 | }, 229 | { 230 | name: "Pride of Ringwood", 231 | origin: "Australia", 232 | alpha_acid: { 233 | value: 10, 234 | unit: "%", 235 | }, 236 | amount: { value: 31.2328, unit: "g" }, 237 | timing: { 238 | use: "add_to_boil", 239 | time: { value: 15, unit: "min" }, 240 | }, 241 | notes: 242 | "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena", 243 | type: "aroma/bittering", 244 | form: "leaf", 245 | beta_acid: { 246 | value: 5.8, 247 | unit: "%", 248 | }, 249 | percent_lost: { 250 | value: 45, 251 | unit: "%", 252 | }, 253 | }, 254 | { 255 | name: "Pride of Ringwood", 256 | origin: "Australia", 257 | alpha_acid: { 258 | value: 10, 259 | unit: "%", 260 | }, 261 | amount: { value: 15.6818, unit: "g" }, 262 | timing: { 263 | use: "add_to_boil", 264 | time: { value: 0, unit: "min" }, 265 | }, 266 | notes: 267 | "Use for: General purpose bittering hops for Australian beers\r\nAroma: Moderate citric aroma, clean bittering flavor\r\nSubstitutes: Cluster, Galena", 268 | type: "bittering", 269 | form: "leaf", 270 | beta_acid: { 271 | value: 5.8, 272 | unit: "%", 273 | }, 274 | percent_lost: { 275 | value: 45, 276 | unit: "%", 277 | }, 278 | }, 279 | ], 280 | culture_additions: [ 281 | { 282 | name: "American Ale", 283 | type: "ale", 284 | form: "liquid", 285 | amount: { 286 | value: 0.125048, 287 | unit: "ml", 288 | }, 289 | producer: "Wyeast Labs", 290 | productId: "1056", 291 | temperature_range: { 292 | minimum: { 293 | value: 15.5555556, 294 | unit: "C", 295 | }, 296 | maximum: { 297 | value: 22.2222222, 298 | unit: "C", 299 | }, 300 | }, 301 | flocculation: "medium", 302 | attenuation: { value: 75, unit: "%" }, 303 | notes: 304 | "Soft, smooth, clean finish. Very well balanced. Very versitile -- works well with many ale styles.", 305 | best_for: 306 | "American Pale Ale, Scottish Ale, Porters, Sweet Stout, Barley Wine, Alt", 307 | max_reuse: 5, 308 | times_cultured: 0, 309 | addToSecondary: false, 310 | cultureDate: "18 Jun 2003", 311 | }, 312 | ], 313 | }, 314 | style: { 315 | name: "Australian Ale", 316 | category: "beer", 317 | category_number: 1, 318 | style_letter: "A", 319 | style_guide: "", 320 | type: "Ale", 321 | original_gravity: { 322 | minimum: { 323 | value: 1.035, 324 | unit: "sg", 325 | }, 326 | maximum: { 327 | value: 1.06, 328 | unit: "sg", 329 | }, 330 | }, 331 | final_gravity: { 332 | minimum: { 333 | value: 1.008, 334 | unit: "sg", 335 | }, 336 | maximum: { 337 | value: 1.015, 338 | unit: "sg", 339 | }, 340 | }, 341 | international_bitterness_units: { 342 | minimum: { 343 | value: 10, 344 | unit: "IBUs", 345 | }, 346 | maximum: { 347 | value: 30, 348 | unit: "IBUs", 349 | }, 350 | }, 351 | color: { 352 | minimum: { 353 | value: 3.94, 354 | unit: "EBC", 355 | }, 356 | maximum: { 357 | value: 19.7, 358 | unit: "EBC", 359 | }, 360 | }, 361 | carbonation: { 362 | minimum: { 363 | value: 2.2, 364 | unit: "vols", 365 | }, 366 | maximum: { 367 | value: 2.8, 368 | unit: "vols", 369 | }, 370 | }, 371 | alcohol_by_volume: { 372 | minimum: { 373 | value: 2, 374 | unit: "%", 375 | }, 376 | maximum: { 377 | value: 5, 378 | unit: "%", 379 | }, 380 | }, 381 | notes: "Medium malt with slight grain dryness", 382 | ingredients: "Pride of Ringwood hops.", 383 | examples: "Cooper's Pale Ale", 384 | }, 385 | mash: { 386 | name: "Single Infusion, Medium Body", 387 | grain_temperature: { 388 | value: 22.22, 389 | unit: "C", 390 | }, 391 | notes: 392 | "Simple single infusion mash for use with most modern well modified grains (about 95% of the time).", 393 | ph: "5.4000000", 394 | mash_steps: [ 395 | { 396 | name: "Mash In", 397 | type: "infusion", 398 | infuse_temperature: { 399 | value: 74.1, 400 | unit: "C", 401 | }, 402 | amount: { 403 | value: 16.76, 404 | //value: 13.7276426, 405 | unit: "l", 406 | }, 407 | step_time: { 408 | value: 60, 409 | unit: "min", 410 | }, 411 | step_temperature: { 412 | value: 66.67, 413 | unit: "C", 414 | }, 415 | }, 416 | { 417 | name: "Mash Out", 418 | type: "infusion", 419 | infuse_temperature: { 420 | value: 98.5, 421 | unit: "C", 422 | }, 423 | amount: { 424 | value: 7.6874799, 425 | unit: "l", 426 | }, 427 | step_time: { 428 | value: 10, 429 | unit: "min", 430 | }, 431 | step_temperature: { 432 | value: 75.55, 433 | unit: "C", 434 | }, 435 | }, 436 | { 437 | name: "Sparge", 438 | type: "sparge", 439 | step_temperature: { 440 | value: 75.5555556, 441 | unit: "C", 442 | }, 443 | step_time: { 444 | value: 10, 445 | unit: "min", 446 | }, 447 | }, 448 | ], 449 | }, 450 | notes: 451 | "FWH the first hop addition.\r\nAllow last addition to sit for 5 minutes to release aroma.", 452 | tasteNotes: 453 | "Very similar to Australian beers in the 60's. Pride of Ringwood is the traditional hop used for a very large number of Australian beers. Although not considered a typical flavour hop it works very well as a single hopped beer. Aim for 50 - 100 ppm of C", 454 | tasteRating: "41.0000000", 455 | og: 1.044, 456 | fg: 1.008, 457 | carbonation: "2.4000000", 458 | fermentationStages: "2", 459 | primaryAge: "4.0000000", 460 | primaryTemp: "19.4444444", 461 | secondaryAge: "10.0000000", 462 | secondaryTemp: "19.4444444", 463 | tertiaryAge: "7.0000000", 464 | age: "30.0000000", 465 | ageTemp: "18.3333333", 466 | carbonationUsed: "Bottle with 133.96 g Corn Sugar", 467 | forcedCarbonation: true, 468 | primingSugarName: "Corn Sugar", 469 | primingSugarEquiv: "1.0000000", 470 | kegPrimingFactor: "0.5000000", 471 | carbonationTemp: "21.1111111", 472 | displayCarbTemp: "21.1 C", 473 | date: "14 May 2011", 474 | estOg: "1.044 SG", 475 | estFg: "1.008 SG", 476 | estColor: "16.8 EBC", 477 | ibu: "28.0 IBUs", 478 | ibuMethod: "Tinseth", 479 | estAbv: "4.7 %", 480 | abv: "4.7 %", 481 | actualEfficiency: "68.0 %", 482 | calories: "405.4 kcal/l", 483 | displayBatchSize: "23.02 l", 484 | displayBoilSize: "37.12 l", 485 | displayOg: "1.044 SG", 486 | displayFg: "1.008 SG", 487 | displayPrimaryTemp: "19.4 C", 488 | displaySecondaryTemp: "19.4 C", 489 | displayTertiaryTemp: "18.3 C", 490 | displayAgeTemp: "18.3 C", 491 | }; 492 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | table.table { 8 | margin-bottom: 0; 9 | } 10 | 11 | .App { 12 | padding-bottom: 16px; 13 | } 14 | 15 | .card { 16 | margin-top: 8px; 17 | } 18 | 19 | .accordion { 20 | margin: 8px 0; 21 | } 22 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import registerServiceWorker from './registerServiceWorker' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | registerServiceWorker() 9 | -------------------------------------------------------------------------------- /app/src/redux/reducers/updateEditor.js: -------------------------------------------------------------------------------- 1 | import { recipe } from '../../data/recipe' 2 | import { equipment } from '../../data/equipment' 3 | 4 | const defaultState = { 5 | editorState: JSON.stringify({ recipe, equipment }, null, 4) 6 | } 7 | 8 | const updateEditor = (state = defaultState, { payload, type }) => { 9 | if (type === 'UPDATE_EDITOR_STATE') { 10 | return { 11 | ...state, 12 | editorState: payload 13 | } 14 | } 15 | return state 16 | } 17 | 18 | export const persistedState = localStorage.getItem('brewCalcState') 19 | ? JSON.parse(localStorage.getItem('brewCalcState')) 20 | : defaultState 21 | 22 | export default updateEditor 23 | -------------------------------------------------------------------------------- /app/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ) 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location) 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 34 | 35 | if (!isLocalhost) { 36 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl) 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.') 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.') 65 | } 66 | } 67 | } 68 | } 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error) 72 | }) 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload() 88 | }) 89 | }) 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl) 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ) 99 | }) 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister() 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/setupTests.js: -------------------------------------------------------------------------------- 1 | const localStorageMock = (() => { 2 | let store = {} 3 | return { 4 | getItem(key) { 5 | return store[key] 6 | }, 7 | setItem(key, value) { 8 | store[key] = value.toString() 9 | }, 10 | clear() { 11 | store = {} 12 | } 13 | } 14 | })() 15 | 16 | global.localStorage = localStorageMock -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | rootDir: process.cwd(), 5 | roots: ["/tests"], 6 | testEnvironment: "node", 7 | transform: { 8 | "^.+\\.(t|j)s$": path.resolve(__dirname, "jest.transform.js"), 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /jest.transform.js: -------------------------------------------------------------------------------- 1 | const babelrc = { 2 | babelrc: false, 3 | presets: ["@babel/preset-env", "@babel/preset-typescript"], 4 | plugins: [ 5 | "@babel/plugin-proposal-class-properties", 6 | "@babel/plugin-proposal-export-default-from", 7 | "@babel/plugin-proposal-export-namespace-from", 8 | "@babel/plugin-transform-runtime", 9 | "@babel/plugin-transform-react-jsx", 10 | ], 11 | }; 12 | 13 | module.exports = require("babel-jest").createTransformer(babelrc); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brewcalc", 3 | "version": "0.2.4", 4 | "description": "A modern (ES6) functional JavaScript library for brewing calculations.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "compile": "npx tsc", 8 | "build": "webpack --env build", 9 | "build:dev": "webpack --env dev", 10 | "dev": "webpack --progress --colors --watch --env dev", 11 | "test": "jest", 12 | "test:coverage": "jest --coverage", 13 | "test:watch": "jest --watch", 14 | "pack": "yarn compile && npm pack", 15 | "pack:app": "yarn run pack && mv -v ./brewcalc-* app/brewcalc.tgz" 16 | }, 17 | "directories": { 18 | "lib": "./lib" 19 | }, 20 | "dependencies": { 21 | "@babel/runtime": "^7.0.0" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "7.0.0", 25 | "@babel/plugin-proposal-class-properties": "7.0.0", 26 | "@babel/plugin-proposal-export-default-from": "7.0.0", 27 | "@babel/plugin-proposal-export-namespace-from": "7.0.0", 28 | "@babel/plugin-transform-react-jsx": "7.14.5", 29 | "@babel/plugin-transform-runtime": "7.0.0", 30 | "@babel/preset-env": "7.0.0", 31 | "@babel/preset-typescript": "7.14.5", 32 | "@beerjson/beerjson": "1.0.0", 33 | "@types/jest": "26.0.24", 34 | "babel-core": "^7.0.0-0", 35 | "babel-jest": "26.6.3", 36 | "jest": "26.6.3", 37 | "pixl-xml": "^1.0.13", 38 | "ts-loader": "8.3.0", 39 | "typescript": "4.3.5", 40 | "webpack": "4.46.0", 41 | "webpack-cli": "4.9.0" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/brewcomputer/brewcalc.git" 46 | }, 47 | "keywords": [], 48 | "author": "Yuriy Krutilin", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/brewcomputer/brewcalc/issues" 52 | }, 53 | "homepage": "https://github.com/brewcomputer/brewcalc" 54 | } 55 | -------------------------------------------------------------------------------- /src/abv.ts: -------------------------------------------------------------------------------- 1 | import { sgToPlato } from './utils' 2 | import { convertMeasurableValue } from './units' 3 | import { GravityType, PercentType } from './types/beerjson' 4 | 5 | // http://byo.com/bock/item/408-calculating-alcohol-content-attenuation-extract-and-calories-advanced-homebrewing 6 | // https://www.brewersfriend.com/2011/06/16/alcohol-by-volume-calculator-updated/ 7 | // ABW = (OG points - FG points) * 0.105 8 | // ABV = (OG points - FG points) * 0.132 9 | export const estABW = (ogPts: number, fgPts: number) => (ogPts - fgPts) * 0.105 10 | export const estABV = (ogPts: number, fgPts: number) => (ogPts - fgPts) * 0.132 11 | 12 | // http://beersmith.com/blog/2010/09/07/apparent-and-real-attenuation-for-beer-brewers-part-1/ 13 | const estABVrealExtract = (og: number, fg: number): number => { 14 | const oe = sgToPlato(og) 15 | const ae = sgToPlato(fg) 16 | const re = 0.1808 * oe + 0.8192 * ae 17 | const abw = (oe - re) / (2.0665 - 0.010665 * oe) 18 | const abv = abw * (fg / 0.79661) 19 | 20 | return abv 21 | } 22 | 23 | export const calcABV = (og: GravityType, fg: GravityType): PercentType => { 24 | return { 25 | value: estABVrealExtract( 26 | convertMeasurableValue(og, 'sg'), 27 | convertMeasurableValue(fg, 'sg') 28 | ), 29 | unit: '%', 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/carbonation.ts: -------------------------------------------------------------------------------- 1 | import { celsiusToFahrenheit, litersToGallons } from './utils' 2 | 3 | // https://byo.com/yeast/item/164-balancing-your-draft-system-advanced-brewing 4 | 5 | const kegPressure = (carbVolume: number, t: number) => 6 | Math.max( 7 | 0, 8 | -16.6999 - 9 | 0.0101059 * t + 10 | 0.00116512 * t * t + 11 | 0.173354 * t * carbVolume + 12 | 4.24267 * carbVolume - 13 | 0.0684226 * carbVolume * carbVolume 14 | ) 15 | 16 | // http://www.homebrewtalk.com/showthread.php?t=441383 17 | const primingSugar = (carbVolume, t, batchSize) => 18 | 15.195 * batchSize * (carbVolume - 3.0378 + 5.0062e-2 * t - 2.6555e-4 * t * t) 19 | 20 | const normalizeTemp = (t: number) => Math.max(32.0, celsiusToFahrenheit(t)) 21 | 22 | export const carbonation = ( 23 | carbVolume: number, 24 | t: number, 25 | batchSize: number 26 | ) => { 27 | const sugar = primingSugar( 28 | carbVolume, 29 | normalizeTemp(t), 30 | litersToGallons(batchSize) 31 | ) 32 | 33 | return { 34 | kegPressure: kegPressure(carbVolume, normalizeTemp(t)), 35 | kegSugar: sugar * 0.5, 36 | cornSugar: sugar, 37 | dme: sugar * 1.538, 38 | } 39 | } 40 | 41 | // http://beersmith.com/blog/2011/02/04/counting-calories-in-your-homebrewed-beer/ 42 | // Calorie_from_alcohol = 1881.22 * FG * (OG-FG)/(1.775-OG) 43 | // Calories_from_carbs = 3550.0 * FG * ((0.1808 * OG) + (0.8192 * FG) – 1.0004) 44 | // Total calories – just add the Calories_from_alcohol to Calories_from_carbs 45 | 46 | const caloriesAlc = (og, fg) => 1881.22 * fg * ((og - fg) / (1.775 - og)) 47 | const caloriesExt = (og, fg) => 48 | 3550.0 * fg * (0.1808 * og + 0.8192 * fg - 1.0004) 49 | 50 | export const calcCalories = (og: number, fg: number) => 51 | caloriesAlc(og, fg) + caloriesExt(og, fg) 52 | -------------------------------------------------------------------------------- /src/color.ts: -------------------------------------------------------------------------------- 1 | import { sum } from './utils' 2 | import { convertMeasurableValue } from './units' 3 | import { 4 | VolumeType, 5 | ColorType, 6 | FermentableAdditionType, 7 | } from './types/beerjson' 8 | 9 | // MCU = (weight of grain in lbs)*(color of grain in lovibond) / (volume in gal) SRM = 1.4922 * MCU ^ 0.6859 10 | const mcu2srm = (mcu: number): number => 1.4922 * Math.pow(mcu, 0.6859) 11 | 12 | const calcMCU = (amount: number, color: number): number => 13 | color > 0.56 ? amount * color : 0 14 | 15 | export const calcColor = ( 16 | fermentables: Array, 17 | postBoilVolume: VolumeType 18 | ): ColorType => { 19 | const fermentablesMCU: number[] = fermentables.map( 20 | (fermentable: FermentableAdditionType) => { 21 | return calcMCU( 22 | convertMeasurableValue(fermentable.amount, 'lb'), 23 | convertMeasurableValue(fermentable.color, 'Lovi') 24 | ) 25 | } 26 | ) 27 | 28 | const colorSRM = mcu2srm( 29 | sum(fermentablesMCU) / convertMeasurableValue(postBoilVolume, 'gal') 30 | ) 31 | 32 | return { 33 | unit: 'SRM', 34 | value: colorSRM, 35 | } 36 | } 37 | 38 | export const srmToRgb = (srm: number): { r: number; g: number; b: number } => ({ 39 | r: Math.round(Math.min(255, Math.max(0, 255 * Math.pow(0.975, srm)))), 40 | g: Math.round(Math.min(255, Math.max(0, 255 * Math.pow(0.88, srm)))), 41 | b: Math.round(Math.min(255, Math.max(0, 255 * Math.pow(0.7, srm)))), 42 | }) 43 | 44 | export const srmToCss = (srm: number): string => { 45 | const color = srmToRgb(srm) 46 | 47 | return `rgb(${color.r}, ${color.g}, ${color.b})` 48 | } 49 | -------------------------------------------------------------------------------- /src/converter/converter.ts: -------------------------------------------------------------------------------- 1 | import definitions from './definitions' 2 | 3 | const DEFAULT_PRECISION = 2 4 | 5 | const roundValue = (value: number, precision: number): number => 6 | +value.toFixed(precision) 7 | 8 | /** 9 | * @param {number} value 10 | * @param {string} from 11 | * @param {string} to 12 | * @param {number} precision 13 | * @returns {number} 14 | */ 15 | export const convert = ( 16 | value: number, 17 | from: string, 18 | to: string, 19 | precision?: number 20 | ): number => { 21 | if (value == null) { 22 | throw new Error(`Unable to convert null or undefined!`) 23 | } 24 | 25 | let origin = null 26 | let destination = null 27 | 28 | for (const measurableTypeKey in definitions) { 29 | const measurableType = definitions[measurableTypeKey] 30 | for (const systemKey in measurableType) { 31 | const system = measurableType[systemKey] 32 | if (system.units.hasOwnProperty(from)) { 33 | origin = { unit: system.units[from], system } 34 | } 35 | 36 | if (system.units.hasOwnProperty(to)) { 37 | destination = { unit: system.units[to], system } 38 | } 39 | } 40 | 41 | if (origin != null && destination == null) { 42 | throw new Error( 43 | `Unable to convert [${measurableTypeKey}] unit [${from}] to [${to}]!` 44 | ) 45 | } 46 | 47 | if (origin == null && destination != null) { 48 | throw new Error( 49 | `Unable to convert [${from}] to [${measurableTypeKey}] unit [${to}]!` 50 | ) 51 | } 52 | 53 | if (origin != null && destination != null) { 54 | break 55 | } 56 | } 57 | 58 | if (origin == null) { 59 | throw new Error(`Unit not found [${from}]!`) 60 | } 61 | 62 | if (destination == null) { 63 | throw new Error(`Unit not found [${to}]!`) 64 | } 65 | 66 | const unitPrecision = 67 | destination.unit.precision != null 68 | ? destination.unit.precision 69 | : DEFAULT_PRECISION 70 | 71 | const actualPrecision = precision != null ? precision : unitPrecision 72 | 73 | if (from === to) { 74 | return roundValue(value, actualPrecision) 75 | } 76 | 77 | let result = value * origin.unit.ratio 78 | 79 | if (origin.system !== destination.system) { 80 | result = destination.system.fromBase(origin.system.toBase(result)) 81 | } 82 | 83 | result /= destination.unit.ratio 84 | 85 | return roundValue(result, actualPrecision) 86 | } 87 | -------------------------------------------------------------------------------- /src/converter/definitions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | srmToEbc, 3 | ebcToSrm, 4 | srmToLovibond, 5 | lovibondToSrm, 6 | sgToPlato, 7 | platoToSG, 8 | sgToBrix, 9 | brixToSG, 10 | fahrenheitToCelsius, 11 | celsiusToFahrenheit, 12 | } from '../utils' 13 | 14 | export default { 15 | mass: { 16 | metric: { 17 | toBase: (v) => v, 18 | fromBase: (v) => v, 19 | units: { 20 | mg: { 21 | ratio: 0.001, 22 | }, 23 | g: { 24 | ratio: 1, 25 | }, 26 | kg: { 27 | ratio: 1000, 28 | }, 29 | }, 30 | }, 31 | us: { 32 | toBase: (v) => v * 453.592, 33 | fromBase: (v) => v / 453.592, 34 | units: { 35 | lb: { 36 | ratio: 1, 37 | }, 38 | oz: { 39 | ratio: 1 / 16, 40 | }, 41 | }, 42 | }, 43 | }, 44 | 45 | volume: { 46 | metric: { 47 | toBase: (v) => v, 48 | fromBase: (v) => v, 49 | units: { 50 | l: { 51 | ratio: 1, 52 | }, 53 | ml: { 54 | ratio: 0.001, 55 | }, 56 | }, 57 | }, 58 | 59 | imperial: { 60 | toBase: (v) => v * 1.136523, 61 | fromBase: (v) => v / 1.136523, 62 | units: { 63 | ifloz: { 64 | ratio: 1 / 40, 65 | }, 66 | ipt: { 67 | ratio: 1 / 2, 68 | }, 69 | iqt: { 70 | ratio: 1, 71 | }, 72 | igal: { 73 | ratio: 4, 74 | }, 75 | ibbl: { 76 | ratio: 144, 77 | }, 78 | }, 79 | }, 80 | 81 | us: { 82 | toBase: (v) => v * 0.946353, 83 | fromBase: (v) => v / 0.946353, 84 | units: { 85 | tsp: { 86 | ratio: 1 / 192, 87 | }, 88 | tbsp: { 89 | ratio: 1 / 64, 90 | }, 91 | floz: { 92 | ratio: 1 / 32, 93 | }, 94 | cup: { 95 | ratio: 1 / 4, 96 | }, 97 | pt: { 98 | ratio: 1 / 2, 99 | }, 100 | qt: { 101 | ratio: 1, 102 | }, 103 | gal: { 104 | ratio: 4, 105 | }, 106 | bbl: { 107 | ratio: 124, 108 | }, 109 | }, 110 | }, 111 | }, 112 | 113 | color: { 114 | lovibond: { 115 | toBase: lovibondToSrm, 116 | fromBase: srmToLovibond, 117 | units: { 118 | Lovi: { 119 | ratio: 1, 120 | }, 121 | }, 122 | }, 123 | 124 | ebc: { 125 | toBase: ebcToSrm, 126 | fromBase: srmToEbc, 127 | units: { 128 | EBC: { 129 | ratio: 1, 130 | }, 131 | }, 132 | }, 133 | 134 | srm: { 135 | toBase: (v) => v, 136 | fromBase: (v) => v, 137 | units: { 138 | SRM: { 139 | ratio: 1, 140 | }, 141 | srm: { 142 | ratio: 1, 143 | }, 144 | }, 145 | }, 146 | }, 147 | 148 | gravity: { 149 | sg: { 150 | toBase: (v) => v, 151 | fromBase: (v) => v, 152 | units: { 153 | sg: { 154 | ratio: 1, 155 | precision: 4, 156 | }, 157 | }, 158 | }, 159 | 160 | plato: { 161 | toBase: platoToSG, 162 | fromBase: sgToPlato, 163 | units: { 164 | plato: { 165 | ratio: 1, 166 | }, 167 | }, 168 | }, 169 | 170 | brix: { 171 | toBase: brixToSG, 172 | fromBase: sgToBrix, 173 | units: { 174 | brix: { 175 | ratio: 1, 176 | }, 177 | }, 178 | }, 179 | }, 180 | 181 | temperature: { 182 | celsius: { 183 | toBase: (v) => v, 184 | fromBase: (v) => v, 185 | units: { 186 | C: { 187 | ratio: 1, 188 | precision: 0, 189 | }, 190 | }, 191 | }, 192 | 193 | fahrenheit: { 194 | toBase: fahrenheitToCelsius, 195 | fromBase: celsiusToFahrenheit, 196 | units: { 197 | F: { 198 | ratio: 1, 199 | precision: 0, 200 | }, 201 | }, 202 | }, 203 | }, 204 | 205 | time: { 206 | time: { 207 | toBase: (v) => v, 208 | fromBase: (v) => v, 209 | units: { 210 | sec: { 211 | ratio: 1 / 60, 212 | }, 213 | min: { 214 | ratio: 1, 215 | }, 216 | hr: { 217 | ratio: 60, 218 | }, 219 | day: { 220 | ratio: 60 * 24, 221 | }, 222 | week: { 223 | ratio: 60 * 24 * 7, 224 | }, 225 | month: { 226 | ratio: 60 * 24 * 30, 227 | }, 228 | year: { 229 | ratio: 60 * 24 * 365, 230 | }, 231 | }, 232 | }, 233 | }, 234 | 235 | pressure: { 236 | pressure: { 237 | toBase: (v) => v, 238 | fromBase: (v) => v, 239 | units: { 240 | kPa: { 241 | ratio: 1, 242 | }, 243 | atm: { 244 | ratio: 101.325, 245 | }, 246 | bar: { 247 | ratio: 100, 248 | }, 249 | psi: { 250 | ratio: 6.894757, 251 | }, 252 | }, 253 | }, 254 | }, 255 | } 256 | -------------------------------------------------------------------------------- /src/culture.ts: -------------------------------------------------------------------------------- 1 | import { litersToGallons, poundsTokg, sgToPlato } from './utils' 2 | import type { Yeast } from './types/yeast' 3 | import { YeastForms } from './types/yeast' 4 | 5 | // https://www.brewersfriend.com/yeast-pitch-rate-and-starter-calculator/ 6 | 7 | // million cells / ml / degree Plato 8 | 9 | // Minimum manufacturer's recommendation: 0.35 (ale only, fresh yeast only) 10 | // Middle of the road Pro Brewer 0.75 (ale) 11 | // Pro Brewer 1.00 (high gravity ale) 12 | // Pro Brewer 1.50 (minimum for lager) 13 | // Pro Brewer 2.0 (high gravity lager) 14 | 15 | // cellDensity = billion cells / gram 16 | // Safale K-97 14 17 | // Safale S-04 8 18 | // Safbrew T-58 18 19 | // Safbrew S-33 16 20 | // Saflager S-23 10 21 | // Saflager S-189 9 22 | 23 | // A pack/vial contains 100 billion cells at the date of manufacture. 24 | // Liquid yeast viability drops 21% each month, or 0.7% each day, from the date of manufacture. 25 | // The assumption is the yeast viability drops in a linear fashion. In 4.75 months or 143 days, this calculator assumes the yeast is 100% dead (100 / 0.7 = ~143). 26 | 27 | // million 10 ^ 6 28 | // billion 10 ^ 9 29 | 30 | export const yeastNeeded = (pitchRate: number, batchSize: number, e: number) => 31 | (pitchRate * (batchSize * 1000) * e) / 1000 32 | 33 | const viability = ( 34 | currentDate: string, 35 | cultureDate: string = new Date().toString() 36 | ) => 37 | 100 - 38 | Math.floor((Date.parse(currentDate) - Date.parse(cultureDate)) / 86400000) * 39 | 0.7 40 | 41 | export const yeastCount = ( 42 | { amount, form, cultureDate }: Yeast, 43 | currentDate: string = new Date().toString(), 44 | cellDensity: number = 8, 45 | // billion cells / ml 46 | slurryDensity: number = 1 47 | ) => { 48 | switch (form) { 49 | case YeastForms.dry: 50 | return cellDensity * amount * 1000 51 | case YeastForms.liquid: 52 | return 100 * (viability(currentDate, cultureDate) / 100) * amount 53 | case YeastForms.slant: 54 | return slurryDensity * amount * 1000 55 | default: 56 | throw new Error('NotImplementedError') 57 | } 58 | } 59 | 60 | const yeastGrowth = (ratio) => 2.33 - 0.67 * ratio 61 | 62 | const growthRateCurveBraukaiserStir = (ratio: number) => 63 | ratio < 1.4 64 | ? 1.4 65 | : ratio >= 1.4 && ratio <= 3.5 && yeastGrowth(ratio) > 0 66 | ? yeastGrowth(ratio) 67 | : 0 68 | 69 | export const yeastStarterGrow = ( 70 | startingYeastCount: number, 71 | starterSize: number, 72 | gravity: number, 73 | batchSize: number 74 | ) => { 75 | const volumeLevel = litersToGallons(starterSize) 76 | const pointsNeeded = volumeLevel * (gravity - 1) * 1000 77 | const poundsDME = pointsNeeded / 42 78 | const gramsDME = poundsTokg(poundsDME) * 1000 79 | const cellsToGramsRatio = startingYeastCount / gramsDME 80 | 81 | const growthRate = growthRateCurveBraukaiserStir(cellsToGramsRatio) 82 | const endingCount = gramsDME * growthRate + startingYeastCount 83 | const pitchRate = 84 | (endingCount * 1000) / sgToPlato(gravity) / (batchSize / 1000) 85 | 86 | return { 87 | growthRate: growthRate, 88 | endingCount: endingCount, 89 | pitchRate: pitchRate, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/gravity.ts: -------------------------------------------------------------------------------- 1 | import { sum } from './utils' 2 | import { 3 | VolumeType, 4 | GravityType, 5 | YieldType, 6 | EfficiencyType, 7 | PercentType, 8 | FermentableAdditionType, 9 | CultureAdditionType, 10 | } from './types/beerjson' 11 | import { convertMeasurableValue } from './units' 12 | 13 | // Sugar provides 46 gravity points per pound, per gallon (PPPG). 14 | // 1 pound = 16 oz (weight/mass) 15 | // 1 gallon = 128 fl oz 16 | // yield and efficiency should be parsed from recipe as percent values 17 | // The maximum potential is approximately 1.046 which would be a pound of pure sugar in a gallon of water. 18 | 19 | const yieldToPotential = (fermentableYield: PercentType): GravityType => ({ 20 | value: (fermentableYield.value * 0.01 * 46) / 1000 + 1, 21 | unit: 'sg', 22 | }) 23 | 24 | const calcFermentableEfficiency = ( 25 | type: string, 26 | equipmentEfficiency: number, 27 | sugarEfficiency = 1 28 | ) => 29 | type === 'extract' || type === 'sugar' || type === 'dry extract' 30 | ? sugarEfficiency 31 | : equipmentEfficiency 32 | 33 | const calcFermentablePotential = (fermentableYield: YieldType): GravityType => { 34 | if (fermentableYield.potential != null) { 35 | return fermentableYield.potential 36 | } 37 | if (fermentableYield.fine_grind != null) { 38 | return yieldToPotential(fermentableYield.fine_grind) 39 | } 40 | if (fermentableYield.coarse_grind != null) { 41 | return yieldToPotential(fermentableYield.coarse_grind) 42 | } 43 | return { value: 0, unit: 'sg' } 44 | } 45 | 46 | const calcFermentableGravityPoints = ( 47 | fermentable: FermentableAdditionType, 48 | brewhouseEfficiency: PercentType = { value: 100, unit: '%' }, 49 | attenuation: PercentType = { value: 0, unit: '%' } 50 | ) => { 51 | const amountValue = convertMeasurableValue(fermentable.amount, 'lb') 52 | const potentialValue = convertMeasurableValue( 53 | calcFermentablePotential(fermentable.yield), 54 | 'sg' 55 | ) 56 | 57 | const efficiencyValue: number = 58 | (1 - attenuation.value / 100) * 59 | calcFermentableEfficiency(fermentable.type, brewhouseEfficiency.value / 100) 60 | 61 | return (potentialValue - 1) * amountValue * efficiencyValue 62 | } 63 | 64 | export const calcTotalGravityPoints = ( 65 | fermentables: Array, 66 | efficiency: EfficiencyType, 67 | attenuation?: PercentType 68 | ) => 69 | sum( 70 | fermentables.map((fermentable: FermentableAdditionType) => 71 | calcFermentableGravityPoints( 72 | fermentable, 73 | efficiency.brewhouse, 74 | attenuation 75 | ) 76 | ) 77 | ) 78 | 79 | const calcGravity = (batchSize: VolumeType, gravityPoints: number): number => { 80 | return 1.0 + gravityPoints / convertMeasurableValue(batchSize, 'gal') 81 | } 82 | 83 | const boilGravity = ( 84 | batchSizeInGallons: number, 85 | boilSizeInGallons: number, 86 | ogInSG: number 87 | ): number => 1 + ((ogInSG - 1) * batchSizeInGallons) / boilSizeInGallons 88 | 89 | export const calcOriginalGravity = ( 90 | batchSize: VolumeType, 91 | fermentables: Array, 92 | efficiency: EfficiencyType 93 | ): GravityType => { 94 | const ogValue = calcGravity( 95 | batchSize, 96 | calcTotalGravityPoints(fermentables, efficiency) 97 | ) 98 | return { 99 | unit: 'sg', 100 | value: ogValue, 101 | } 102 | } 103 | 104 | export const calcFinalGravity = ( 105 | batchSize: VolumeType, 106 | fermentables: Array, 107 | efficiency: EfficiencyType, 108 | cultures: Array 109 | ): GravityType => { 110 | const fgValue = calcGravity( 111 | batchSize, 112 | calcTotalGravityPoints(fermentables, efficiency, cultures[0].attenuation) 113 | ) 114 | return { 115 | unit: 'sg', 116 | value: fgValue, 117 | } 118 | } 119 | 120 | export const calcBoilGravity = ( 121 | batchSize: VolumeType, 122 | boilSize: VolumeType, 123 | OG: GravityType 124 | ): GravityType => { 125 | return { 126 | unit: 'sg', 127 | value: boilGravity( 128 | convertMeasurableValue(batchSize, 'gal'), 129 | convertMeasurableValue(boilSize, 'gal'), 130 | convertMeasurableValue(OG, 'sg') 131 | ), 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/hops.ts: -------------------------------------------------------------------------------- 1 | import { sum } from './utils' 2 | import { convertMeasurableValue } from './units' 3 | import { use, boilTime } from './timing' 4 | import { 5 | VolumeType, 6 | HopAdditionType, 7 | BitternessType, 8 | GravityType, 9 | } from './types/beerjson' 10 | 11 | const alphaAcidUnits = (amountInOz: number, alphaAcid: number): number => 12 | amountInOz * alphaAcid 13 | 14 | const gravityFactor = (boilGravityValue: number): number => 15 | 1.65 * Math.pow(0.000125, boilGravityValue - 1) 16 | 17 | const timeFactor = (boilTimeInMin: number): number => 18 | (1 - Math.exp(-0.04 * boilTimeInMin)) / 4.15 19 | 20 | const pelletFactor = (form: string = ''): number => 21 | form === 'pellet' ? 1.1 : 1 22 | 23 | const ibuUtilization = ( 24 | hopForm: string = '', 25 | boilGravityValue: number, 26 | boilTimeInMin: number = 0 27 | ) => 28 | pelletFactor(hopForm) * 29 | gravityFactor(boilGravityValue) * 30 | timeFactor(boilTimeInMin) 31 | 32 | // Glenn Tinseth developed the following formula to calculate bitterness in IBUs: 33 | // IBU = (U * ozs hops * 7490)/Volume (in gallons) U represents the utilization of the hops (conversion to iso-alpha-acids) based on boil time and wort gravity. 34 | // U = bigness factor * boil time factor 35 | 36 | // http://www.howtobrew.com/book/section-1/hops/hop-bittering-calculations 37 | 38 | export const bitternessIbuTinseth = ( 39 | hops: Array, 40 | boilGravity: GravityType, 41 | postBoilVolume: VolumeType 42 | ): BitternessType => { 43 | const bitterness = sum( 44 | hops.map(({ amount, alpha_acid, form, timing }) => { 45 | // TODO: research needed 46 | 47 | if (!use(timing).add_to_boil) { 48 | return 0 49 | } 50 | 51 | const AAU = alphaAcidUnits( 52 | convertMeasurableValue(amount, 'oz'), 53 | alpha_acid.value 54 | ) 55 | const U = ibuUtilization( 56 | form, 57 | convertMeasurableValue(boilGravity, 'sg'), 58 | boilTime(timing) 59 | ) 60 | 61 | return (U * AAU * 74.89) / convertMeasurableValue(postBoilVolume, 'gal') 62 | }) 63 | ) 64 | 65 | return { 66 | value: bitterness, 67 | unit: 'IBUs', 68 | } 69 | } 70 | 71 | // The preceived bitterness expressed in a ratio of IBUs to gravity. This is frequently seen expressed as BU/GU. 72 | // The Gravity Units are the decimal portion of the original gravity 73 | // http://beersmith.com/blog/2009/09/26/balancing-your-beer-with-the-bitterness-ratio/ 74 | export const bitternessRatio = (ibu: number, gu: number) => ibu / gu 75 | 76 | // rager 77 | const ragerHopGravityAdjustment = (sgb) => 78 | sgb <= 1.05 ? 0 : (sgb - 1.05) / 0.2 79 | const ragerUtil = (time) => 18.11 + 13.86 * Math.tanh((time - 31.32) / 18.27) 80 | 81 | const ragerHopIbu = ( 82 | hop: HopAdditionType, 83 | boilGravity: GravityType, 84 | postBoilVolume: VolumeType 85 | ): number => { 86 | if (!use(hop.timing).add_to_boil) { 87 | return 0 88 | } 89 | 90 | const U = 91 | (ragerUtil(Math.floor(boilTime(hop.timing) + 0.5)) * 92 | pelletFactor(hop.form)) / 93 | 100 94 | const AAU = alphaAcidUnits(hop.amount.value, hop.alpha_acid.value) 95 | 96 | return ( 97 | (U * AAU * 74.89) / 98 | postBoilVolume.value / 99 | (1.0 + ragerHopGravityAdjustment(boilGravity.value)) 100 | ) 101 | } 102 | 103 | export const bitternessIbuRager = ( 104 | hops: Array, 105 | boilGravity: GravityType, 106 | postBoilVolume: VolumeType 107 | ): BitternessType => { 108 | const bitterness = sum( 109 | hops.map((hop: HopAdditionType) => 110 | ragerHopIbu(hop, boilGravity, postBoilVolume) 111 | ) 112 | ) 113 | 114 | return { 115 | value: bitterness, 116 | unit: 'IBUs', 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { yeastCount, yeastNeeded, yeastStarterGrow } from './culture' 2 | import { calcCalories, carbonation } from './carbonation' 3 | 4 | import { 5 | bitternessIbuRager, 6 | bitternessIbuTinseth, 7 | bitternessRatio, 8 | } from './hops' 9 | 10 | import { isNotEmptyArray, roundMeasurable } from './utils' 11 | 12 | import { convertMeasurableValue } from './units' 13 | import { convert } from './converter/converter' 14 | 15 | import { 16 | calcMashGrainWeight, 17 | recalculateMashSteps, 18 | updateSpargeVolume, 19 | } from './mash' 20 | import { calcBoilVolumes, calcMashVolumes } from './volumes' 21 | import { calcWaterChemistry } from './waterChem' 22 | 23 | import type { 24 | RecipeType, 25 | MashProcedureType, 26 | EquipmentItemType, 27 | GravityType, 28 | ColorType, 29 | PercentType, 30 | BitternessType, 31 | CultureAdditionType, 32 | VolumeType, 33 | BoilProcedureType, 34 | } from './types/beerjson' 35 | 36 | import { 37 | calcOriginalGravity, 38 | calcFinalGravity, 39 | calcBoilGravity, 40 | } from './gravity' 41 | import { srmToCss, srmToRgb, calcColor } from './color' 42 | import { calcABV } from './abv' 43 | 44 | type Stats = { 45 | original_gravity: GravityType 46 | final_gravity: GravityType 47 | alcohol_by_volume: PercentType 48 | ibu_estimate: BitternessType 49 | color_estimate: ColorType 50 | } 51 | 52 | type Volumes = { 53 | sparge_volume?: VolumeType 54 | mash_volume?: VolumeType 55 | total_volume?: VolumeType 56 | } 57 | 58 | const calculateRecipeBeerJSON = ( 59 | recipe: RecipeType, 60 | mash: MashProcedureType, 61 | equipment: { 62 | hlt?: EquipmentItemType 63 | mash_tun?: EquipmentItemType 64 | brew_kettle?: EquipmentItemType 65 | fermenter?: EquipmentItemType 66 | } 67 | ): { 68 | stats: Stats 69 | mash: MashProcedureType 70 | boil: BoilProcedureType 71 | volumes: Volumes 72 | } => { 73 | const { batch_size, boil, efficiency, ingredients } = recipe 74 | 75 | const { fermentable_additions, hop_additions, culture_additions } = 76 | ingredients 77 | 78 | let original_gravity: GravityType = { 79 | unit: 'sg', 80 | value: null, 81 | } 82 | let final_gravity: GravityType = { 83 | unit: 'sg', 84 | value: null, 85 | } 86 | let color: ColorType = { 87 | unit: 'SRM', 88 | value: null, 89 | } 90 | let ibu: BitternessType = { 91 | unit: 'IBUs', 92 | value: null, 93 | } 94 | let abv: PercentType = { 95 | unit: '%', 96 | value: null, 97 | } 98 | let volumes = null 99 | let calculatedMash = null 100 | let calculatedBoil = null 101 | 102 | if (isNotEmptyArray(fermentable_additions)) { 103 | original_gravity = calcOriginalGravity( 104 | batch_size, 105 | fermentable_additions, 106 | efficiency 107 | ) 108 | 109 | const defaultCultureAddition: CultureAdditionType = { 110 | name: 'Default Culture', 111 | type: 'ale', 112 | form: 'liquid', 113 | attenuation: { value: 75, unit: '%' }, 114 | } 115 | 116 | final_gravity = calcFinalGravity( 117 | batch_size, 118 | fermentable_additions, 119 | efficiency, 120 | isNotEmptyArray(culture_additions) 121 | ? culture_additions 122 | : [defaultCultureAddition] 123 | ) 124 | 125 | abv = calcABV(original_gravity, final_gravity) 126 | 127 | const { pre_boil_size } = calcBoilVolumes(batch_size, boil, equipment) 128 | volumes = { 129 | pre_boil_size, 130 | } 131 | 132 | if (mash) { 133 | const mashGrainWeight = calcMashGrainWeight(fermentable_additions) 134 | 135 | const mashSteps = recalculateMashSteps( 136 | mash.mash_steps, 137 | mash.grain_temperature, 138 | mashGrainWeight 139 | ) 140 | 141 | const { sparge_volume, mash_volume, total_volume } = calcMashVolumes( 142 | pre_boil_size, 143 | mashSteps, 144 | mashGrainWeight, 145 | equipment 146 | ) 147 | 148 | volumes = { 149 | ...volumes, 150 | sparge_volume, 151 | mash_volume, 152 | total_volume, 153 | } 154 | 155 | calculatedMash = { 156 | ...mash, 157 | mash_steps: updateSpargeVolume(mashSteps, sparge_volume), 158 | } 159 | } 160 | 161 | if (boil) { 162 | calculatedBoil = { ...boil, pre_boil_size } 163 | } 164 | 165 | color = calcColor(fermentable_additions, batch_size) 166 | 167 | if (isNotEmptyArray(hop_additions)) { 168 | const boilGravity = calcBoilGravity( 169 | batch_size, 170 | pre_boil_size, 171 | original_gravity 172 | ) 173 | ibu = bitternessIbuTinseth(hop_additions, boilGravity, batch_size) 174 | } 175 | } 176 | 177 | return { 178 | stats: { 179 | original_gravity: roundMeasurable(original_gravity, 3), 180 | final_gravity: roundMeasurable(final_gravity, 3), 181 | alcohol_by_volume: roundMeasurable(abv, 1), 182 | ibu_estimate: roundMeasurable(ibu, 1), 183 | color_estimate: roundMeasurable(color, 1), 184 | }, 185 | volumes, 186 | mash: calculatedMash, 187 | boil: calculatedBoil, 188 | } 189 | } 190 | 191 | export { 192 | convert, 193 | convertMeasurableValue, 194 | calcOriginalGravity, 195 | calcFinalGravity, 196 | calcBoilGravity, 197 | calcColor, 198 | srmToCss, 199 | srmToRgb, 200 | calcABV, 201 | bitternessIbuRager, 202 | bitternessIbuTinseth, 203 | bitternessRatio, 204 | calculateRecipeBeerJSON, 205 | calcBoilVolumes, 206 | calcMashVolumes, 207 | calcMashGrainWeight, 208 | recalculateMashSteps, 209 | //TODO: use beerJSON 210 | calcCalories, 211 | carbonation, 212 | yeastCount, 213 | yeastNeeded, 214 | yeastStarterGrow, 215 | } 216 | -------------------------------------------------------------------------------- /src/mash.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FermentableAdditionType, 3 | MashStepType, 4 | TemperatureType, 5 | VolumeType, 6 | MassType, 7 | } from './types/beerjson' 8 | import { getMeasurableValue, sum } from './utils' 9 | import { convertMeasurableValue } from './units' 10 | import { use } from './timing' 11 | 12 | const grainVolume = 0.652 // l/kg 13 | const boilingTemp = 100 14 | const maltSpecificHeat = 0.38 // Cal/gram-C 15 | const initialWaterGrainRatio = 2.5 // l/kg 16 | 17 | const adjustTunMass = (tunVolume, totVolume, tunMass) => { 18 | tunVolume = tunVolume * 0.8 19 | return tunVolume > 0 && totVolume < tunVolume 20 | ? (tunMass * totVolume) / tunVolume 21 | : tunMass 22 | } 23 | 24 | const calcDecoctionStep = ( 25 | startTemp, 26 | targetTemp, 27 | startVolume, 28 | mashGrainWeight, 29 | tunMass = 0, 30 | tunSpecificHeat = 0, 31 | tunVolume = 0 32 | ): { amount: VolumeType } => { 33 | const totVolume = grainVolume * mashGrainWeight + startVolume 34 | const adjustedTunMass = adjustTunMass(tunVolume, totVolume, tunMass) 35 | let fraction = 36 | (((maltSpecificHeat * mashGrainWeight + 37 | tunSpecificHeat * adjustedTunMass + 38 | startVolume) / 39 | (maltSpecificHeat * mashGrainWeight + startVolume)) * 40 | (targetTemp - startTemp)) / 41 | (boilingTemp - startTemp) 42 | 43 | if (fraction > 1) { 44 | fraction = 1 45 | } 46 | return { amount: { value: totVolume * fraction, unit: 'l' } } 47 | } 48 | 49 | const calcInfusionStep = ( 50 | startTemp, 51 | stepTemp, 52 | startVolume, 53 | index, 54 | mashGrainWeight 55 | ): { 56 | amount: VolumeType 57 | infuse_temperature: TemperatureType 58 | } => { 59 | const infuseTemp = 60 | index > 0 61 | ? boilingTemp 62 | : (maltSpecificHeat * (stepTemp - startTemp)) / initialWaterGrainRatio + 63 | stepTemp 64 | const infuseAmount = 65 | ((mashGrainWeight * maltSpecificHeat + startVolume) * 66 | (stepTemp - startTemp)) / 67 | (infuseTemp - stepTemp) 68 | 69 | return { 70 | infuse_temperature: { 71 | unit: 'C', 72 | value: infuseTemp, 73 | }, 74 | amount: { 75 | unit: 'l', 76 | value: infuseAmount, 77 | }, 78 | } 79 | } 80 | 81 | export function recalculateMashSteps( 82 | mash_steps: Array, 83 | grain_temperature: TemperatureType, 84 | mashGrainWeight: MassType 85 | ): Array { 86 | let startVolume = 0 87 | let startTemp = grain_temperature.value 88 | 89 | const grainWeightValue = convertMeasurableValue(mashGrainWeight, 'kg') 90 | 91 | return mash_steps.map((step: MashStepType, index: number): MashStepType => { 92 | const stepTemp = getMeasurableValue(step.step_temperature) 93 | 94 | switch (step.type) { 95 | case 'decoction': { 96 | const { amount } = calcDecoctionStep( 97 | startTemp, 98 | stepTemp, 99 | startVolume, 100 | grainWeightValue 101 | ) 102 | 103 | return { 104 | ...step, 105 | amount, 106 | } 107 | } 108 | case 'infusion': { 109 | const { amount, infuse_temperature } = calcInfusionStep( 110 | startTemp, 111 | stepTemp, 112 | startVolume, 113 | index, 114 | grainWeightValue 115 | ) 116 | 117 | startVolume += amount.value 118 | startTemp = stepTemp 119 | 120 | return { 121 | ...step, 122 | infuse_temperature, 123 | amount, 124 | } 125 | } 126 | default: 127 | return step 128 | } 129 | }) 130 | } 131 | 132 | export const calcMashGrainWeight = ( 133 | fermentables: Array 134 | ): MassType => { 135 | const value = sum( 136 | fermentables.map(({ timing, type, amount }: FermentableAdditionType) => 137 | type === 'grain' && use(timing).add_to_mash 138 | ? convertMeasurableValue(amount, 'lb') 139 | : 0 140 | ) 141 | ) 142 | return { 143 | value, 144 | unit: 'lb', 145 | } 146 | } 147 | 148 | export function updateSpargeVolume( 149 | mash_steps: Array, 150 | spargeVolume: VolumeType 151 | ): Array { 152 | return mash_steps.map((step) => { 153 | if (step.type === 'sparge') { 154 | return { ...step, amount: spargeVolume } 155 | } 156 | return step 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /src/timing.ts: -------------------------------------------------------------------------------- 1 | import type { TimingType } from './types/beerjson' 2 | 3 | export const use = ( 4 | timing: TimingType = {} 5 | ): { 6 | add_to_boil: boolean 7 | add_to_mash: boolean 8 | } => ({ 9 | add_to_boil: timing.use === 'add_to_boil', 10 | add_to_mash: !timing.use || timing.use === 'add_to_mash', 11 | }) 12 | 13 | export const boilTime = (timing: TimingType = {}): number => 14 | timing.use === 'add_to_boil' ? timing.time.value : 0 15 | -------------------------------------------------------------------------------- /src/types/beerjson.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import '@beerjson/beerjson/types/ts/beerjson' 3 | 4 | export type GravityType = BeerJSON.GravityType 5 | export type PercentType = BeerJSON.PercentType 6 | export type ColorType = BeerJSON.ColorType 7 | export type VolumeType = BeerJSON.VolumeType 8 | export type MassType = BeerJSON.MassType 9 | export type HopAdditionType = BeerJSON.HopAdditionType 10 | export type BitternessType = BeerJSON.BitternessType 11 | export type YieldType = BeerJSON.YieldType 12 | export type EfficiencyType = BeerJSON.EfficiencyType 13 | export type FermentableAdditionType = BeerJSON.FermentableAdditionType 14 | export type CultureAdditionType = BeerJSON.CultureAdditionType 15 | export type TimingType = BeerJSON.TimingType 16 | export type MashProcedureType = BeerJSON.MashProcedureType 17 | export type MashStepType = BeerJSON.MashStepType 18 | export type TemperatureType = BeerJSON.TemperatureType 19 | export type EquipmentItemType = BeerJSON.EquipmentItemType 20 | export type BoilProcedureType = BeerJSON.BoilProcedureType 21 | export type RecipeType = BeerJSON.RecipeType 22 | export type EquipmentType = BeerJSON.EquipmentType 23 | -------------------------------------------------------------------------------- /src/types/saltAdditions.ts: -------------------------------------------------------------------------------- 1 | export type SaltAdditions = { 2 | // Chalk (Calcium Bicarbonate) 3 | CaCO3: number; 4 | // Baking Soda (Sodium Bicarbonate) 5 | NaHCO3: number; 6 | // Gypsum (Calcium Sulfate) 7 | CaSO4: number; 8 | // Calcium Chloride 9 | CaCl2: number; 10 | // Epsom salt (Magnesium Sulfate) 11 | MgSO4: number; 12 | // Table salt (Sodium Chloride) 13 | NaCl: number; 14 | }; 15 | 16 | // Magnesium Chloride MgCl2 | Slaked Lime Ca(OH)2 | 17 | -------------------------------------------------------------------------------- /src/types/water.ts: -------------------------------------------------------------------------------- 1 | export type Water = { 2 | name: string; 3 | //calcium 4 | Ca: number; 5 | //bicarbonate 6 | HCO3: number; 7 | // chloride 8 | Cl: number; 9 | // magnesium 10 | Mg: number; 11 | // sodium 12 | Na: number; 13 | // sulfate 14 | SO4: number; 15 | alkalinity?: number; 16 | }; 17 | -------------------------------------------------------------------------------- /src/types/yeast.ts: -------------------------------------------------------------------------------- 1 | export type Yeast = { 2 | name: string 3 | amount: number 4 | attenuation?: number 5 | form: 'Liquid' | 'Dry' | 'Slant' | 'Culture' 6 | type: 'Ale' | 'Lager' | 'Wheat' | 'Wine' | 'Champagne' 7 | cultureDate?: string 8 | } 9 | 10 | export const YeastTypes = { 11 | ale: 'Ale', 12 | lager: 'Lager', 13 | wheat: 'Wheat', 14 | wine: 'Wine', 15 | champagne: 'Champagne', 16 | } 17 | export const YeastForms = { 18 | liquid: 'Liquid', 19 | dry: 'Dry', 20 | slant: 'Slant', 21 | culture: 'Culture', 22 | } 23 | -------------------------------------------------------------------------------- /src/units.ts: -------------------------------------------------------------------------------- 1 | import { convert } from './converter/converter' 2 | 3 | type Measurable = { 4 | value: number 5 | unit: string 6 | } 7 | 8 | export const convertMeasurableValue = ( 9 | measurable: Measurable, 10 | unit: string, 11 | precision: number = 4 12 | ) => { 13 | return convert(measurable.value, measurable.unit, unit, precision) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const kgToOunces = (k: number) => k * 35.2739619 2 | 3 | export const kgToPounds = (k: number) => kgToOunces(k) / 16 4 | 5 | export const poundsTokg = (p: number) => p / 2.204 6 | 7 | export const litersToOunces = (l: number) => l / 0.0295735 8 | 9 | export const ouncesToLiters = (o: number) => o * 0.0295735 10 | 11 | export const litersToGallons = (l: number) => litersToOunces(l) / 128 12 | 13 | export const gallonsToLiters = (g: number) => ouncesToLiters(g * 128) 14 | 15 | export const fahrenheitToCelsius = (f: number) => (f - 32) / 1.8 16 | 17 | export const celsiusToFahrenheit = (c: number) => c * 1.8 + 32 18 | 19 | export const kpaToPsi = (kpa: number) => kpa * 0.14503773773020923 20 | 21 | export const psiTokpa = (psi: number) => psi * 6.894757293168361 22 | 23 | export const sgToPlato = (sg: number) => 24 | -616.868 + 25 | 1111.14 * sg - 26 | 630.272 * Math.pow(sg, 2) + 27 | 135.997 * Math.pow(sg, 3) 28 | 29 | export const platoToSG = (e: number) => 1 + e / (258.6 - (e / 258.2) * 227.1) 30 | 31 | export const brixToSG = (brix: number) => 32 | brix / (258.6 - (brix / 258.2) * 227.1) + 1 33 | 34 | export const sgToBrix = (sg: number) => 35 | -669.5622 + 36 | 1262.7749 * sg - 37 | 775.6821 * Math.pow(sg, 2) + 38 | 182.4601 * Math.pow(sg, 3) 39 | 40 | export const srmToEbc = (srm: number) => srm * 1.97 41 | 42 | export const ebcToSrm = (ebc: number) => ebc * 0.508 43 | 44 | export const srmToLovibond = (srm: number) => (srm + 0.76) / 1.3546 45 | 46 | export const lovibondToSrm = (lovibond: number) => 1.3546 * lovibond - 0.76 47 | 48 | export const sum = (array: Array): number => 49 | array.reduce((pv, cv) => pv + cv, 0) 50 | 51 | const scaleIngredients = (scaleFactor: number, ingredients: any) => 52 | ingredients.map((i) => { 53 | return { 54 | ...i, 55 | amount: scaleFactor * i.amount, 56 | } 57 | }) 58 | 59 | export const capitalize = (str: string): string => { 60 | const words: Array = str.split(' ') 61 | const capitalizedWords: Array = words.map( 62 | (word) => word.charAt(0).toUpperCase() + word.slice(1) 63 | ) 64 | return capitalizedWords.join(' ') 65 | } 66 | 67 | export const isNotEmptyArray = (arr: Array): boolean => { 68 | if (Array.isArray(arr)) { 69 | return arr.length > 0 70 | } 71 | return false 72 | } 73 | 74 | export function round(number, precision = 0) { 75 | if (typeof number === 'number') { 76 | return Number(number.toFixed(precision)) 77 | } 78 | return null 79 | } 80 | 81 | export function isObject(object) { 82 | return object != null && typeof object === 'object' 83 | } 84 | 85 | export function isMeasurable(object) { 86 | return ( 87 | isObject(object) && 88 | object.hasOwnProperty('value') && 89 | object.hasOwnProperty('unit') 90 | ) 91 | } 92 | 93 | export function getMeasurableValue(measurable) { 94 | if (isMeasurable(measurable)) { 95 | return measurable.value 96 | } 97 | return null 98 | } 99 | 100 | export const roundMeasurable = (m, precision) => { 101 | return { 102 | unit: m.unit, 103 | value: round(m.value, precision), 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/volumes.ts: -------------------------------------------------------------------------------- 1 | import { sum } from './utils' 2 | import { 3 | VolumeType, 4 | MassType, 5 | EquipmentItemType, 6 | BoilProcedureType, 7 | MashStepType, 8 | } from './types/beerjson' 9 | import { convertMeasurableValue } from './units' 10 | 11 | const defaultBoil: BoilProcedureType = { 12 | pre_boil_size: { 13 | value: 0, 14 | unit: 'gal', 15 | }, 16 | boil_time: { 17 | value: 0, 18 | unit: 'min', 19 | }, 20 | } 21 | 22 | const coolingShrinkageRate = 0.04 23 | 24 | const convertToGallons = (volume: VolumeType) => 25 | convertMeasurableValue(volume, 'gal') 26 | 27 | // 0.96 - number of fl. ounces of water absorbed per ounce of the grain 28 | // 128 fl. ounces in gallon, 16 ounces in pound 29 | const grainAbsorptionRatio = (0.96 / 128) * 16 30 | 31 | const calcGrainAbsorption = (grainWeight: MassType): VolumeType => { 32 | const value = convertMeasurableValue(grainWeight, 'lb') * grainAbsorptionRatio 33 | return { 34 | value, 35 | unit: 'gal', 36 | } 37 | } 38 | 39 | const calcMashWaterVolume = ( 40 | mash_steps: Array = [] 41 | ): VolumeType => { 42 | const value = sum( 43 | mash_steps.map(({ type, amount }: MashStepType) => 44 | type === 'infusion' ? convertToGallons(amount) : 0 45 | ) 46 | ) 47 | return { 48 | value, 49 | unit: 'gal', 50 | } 51 | } 52 | 53 | export const calcMashVolumes = ( 54 | pre_boil_size: VolumeType, 55 | mashSteps: Array, 56 | mashGrainWeight: MassType, 57 | equipment: { 58 | hlt?: EquipmentItemType 59 | mash_tun?: EquipmentItemType 60 | brew_kettle?: EquipmentItemType 61 | fermenter?: EquipmentItemType 62 | } 63 | ): { 64 | mash_volume: VolumeType 65 | sparge_volume: VolumeType 66 | total_volume: VolumeType 67 | } => { 68 | const mashWaterVolume = calcMashWaterVolume(mashSteps) 69 | 70 | const grainAbsorption = calcGrainAbsorption(mashGrainWeight) 71 | 72 | const mashLoss = 73 | equipment.mash_tun != null ? convertToGallons(equipment.mash_tun.loss) : 0 74 | 75 | const spargeVolumeValue = 76 | convertToGallons(pre_boil_size) + 77 | grainAbsorption.value - 78 | mashWaterVolume.value + 79 | mashLoss 80 | 81 | const spargeVolume: VolumeType = { 82 | value: spargeVolumeValue, 83 | unit: 'gal', 84 | } 85 | 86 | const totalVolume: VolumeType = { 87 | value: mashWaterVolume.value + spargeVolume.value, 88 | unit: 'gal', 89 | } 90 | 91 | return { 92 | mash_volume: mashWaterVolume, 93 | sparge_volume: spargeVolume, 94 | total_volume: totalVolume, 95 | } 96 | } 97 | 98 | export const calcBoilVolumes = ( 99 | batch_size: VolumeType, 100 | boil: BoilProcedureType = defaultBoil, 101 | equipment: { 102 | hlt?: EquipmentItemType 103 | mash_tun?: EquipmentItemType 104 | brew_kettle?: EquipmentItemType 105 | fermenter?: EquipmentItemType 106 | } 107 | ): { pre_boil_size: VolumeType } => { 108 | const boilProfile = boil || defaultBoil 109 | 110 | const postBoilVolume = convertToGallons(batch_size) 111 | 112 | let boilLoss = 0 113 | let boilRate = 0 114 | if (equipment != null && equipment.brew_kettle != null) { 115 | boilLoss = convertToGallons(equipment.brew_kettle.loss) 116 | boilRate = convertToGallons(equipment.brew_kettle.boil_rate_per_hour) 117 | } 118 | 119 | const boilOffVolume = (boilRate * boilProfile.boil_time.value) / 60 120 | const coolingShrinkage = postBoilVolume * coolingShrinkageRate 121 | const preBoilVolume = 122 | postBoilVolume + boilOffVolume + boilLoss + coolingShrinkage 123 | 124 | return { 125 | pre_boil_size: { 126 | value: preBoilVolume, 127 | unit: 'gal', 128 | }, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/waterChem.ts: -------------------------------------------------------------------------------- 1 | import type { Water } from "./types/water"; 2 | import type { SaltAdditions } from "./types/saltAdditions"; 3 | import { convertMeasurableValue } from "./units"; 4 | import { VolumeType } from "./types/beerjson"; 5 | 6 | const dilute = (value: number, dilution: number) => 7 | Math.round(value * (1 - dilution)); 8 | 9 | const alkalinity = (value: number, dilution: number = 0) => 10 | Math.round(value * (1 - dilution) * (50 / 61)); 11 | 12 | const adjustmentsFromSalts = ( 13 | batch_size: VolumeType, 14 | { CaCO3, NaHCO3, CaSO4, CaCl2, MgSO4, NaCl }: SaltAdditions 15 | ) => { 16 | const batchSize = convertMeasurableValue(batch_size, "gal"); 17 | 18 | let adjCa = 0; 19 | let adjMg = 0; 20 | let adjSO4 = 0; 21 | let adjNa = 0; 22 | let adjCl = 0; 23 | let adjHCO3 = 0; 24 | 25 | CaCO3 = CaCO3 / 2; 26 | 27 | if (CaCO3 > 0) { 28 | adjCa = adjCa + (105 * CaCO3) / batchSize; 29 | adjHCO3 = adjHCO3 + (321 * CaCO3) / batchSize; 30 | } 31 | if (NaHCO3 > 0) { 32 | adjNa = adjNa + (75 * NaHCO3) / batchSize; 33 | adjHCO3 = adjHCO3 + (191 * NaHCO3) / batchSize; 34 | } 35 | if (CaSO4 > 0) { 36 | adjCa = adjCa + (61.5 * CaSO4) / batchSize; 37 | adjSO4 = adjSO4 + (147.4 * CaSO4) / batchSize; 38 | } 39 | if (CaCl2 > 0) { 40 | adjCa = adjCa + (72 * CaCl2) / batchSize; 41 | adjCl = adjCl + (127 * CaCl2) / batchSize; 42 | } 43 | if (MgSO4 > 0) { 44 | adjMg = adjMg + (26 * MgSO4) / batchSize; 45 | adjSO4 = adjSO4 + (103 * MgSO4) / batchSize; 46 | } 47 | if (NaCl > 0) { 48 | adjNa = adjNa + (104 * NaCl) / batchSize; 49 | adjCl = adjCl + (160 * NaCl) / batchSize; 50 | } 51 | return { 52 | name: "adjustmentsFromSalts", 53 | Ca: Math.round(adjCa), 54 | Mg: Math.round(adjMg), 55 | SO4: Math.round(adjSO4), 56 | Na: Math.round(adjNa), 57 | Cl: Math.round(adjCl), 58 | HCO3: Math.round(adjHCO3), 59 | alkalinity: alkalinity(Math.round(adjHCO3)), 60 | }; 61 | }; 62 | 63 | export const calcWaterChemistry = ( 64 | batch_size: VolumeType, 65 | dilution: number, 66 | source: Water, 67 | target: Water, 68 | salts: SaltAdditions 69 | ) => { 70 | const adjustmentsFromSaltsWater: Water = adjustmentsFromSalts( 71 | batch_size, 72 | salts 73 | ); 74 | 75 | const dilutedWater: Water = { 76 | name: "dilutedWater", 77 | Ca: dilute(source.Ca, dilution), 78 | Mg: dilute(source.Mg, dilution), 79 | SO4: dilute(source.SO4, dilution), 80 | Na: dilute(source.Na, dilution), 81 | Cl: dilute(source.Cl, dilution), 82 | HCO3: dilute(source.HCO3, dilution), 83 | alkalinity: alkalinity(source.HCO3, dilution), 84 | }; 85 | 86 | const adjustedWater: Water = { 87 | name: "adjustedWater", 88 | Ca: dilutedWater.Ca + adjustmentsFromSaltsWater.Ca, 89 | Mg: dilutedWater.Mg + adjustmentsFromSaltsWater.Mg, 90 | SO4: dilutedWater.SO4 + adjustmentsFromSaltsWater.SO4, 91 | Na: dilutedWater.Na + adjustmentsFromSaltsWater.Na, 92 | Cl: dilutedWater.Cl + adjustmentsFromSaltsWater.Cl, 93 | HCO3: dilutedWater.HCO3 + adjustmentsFromSaltsWater.HCO3, 94 | alkalinity: alkalinity(dilutedWater.HCO3 + adjustmentsFromSaltsWater.HCO3), 95 | }; 96 | 97 | const difference: Water = { 98 | name: "difference source water from target", 99 | Ca: adjustedWater.Ca - target.Ca, 100 | Mg: adjustedWater.Mg - target.Mg, 101 | SO4: adjustedWater.SO4 - target.SO4, 102 | Na: adjustedWater.Na - target.Na, 103 | Cl: adjustedWater.Cl - target.Cl, 104 | HCO3: adjustedWater.HCO3 - target.HCO3, 105 | alkalinity: alkalinity(adjustedWater.HCO3 - target.HCO3), 106 | }; 107 | 108 | return { 109 | adjustedWater: adjustedWater, 110 | dilutedWater: dilutedWater, 111 | adjustmentsFromSalts: adjustmentsFromSaltsWater, 112 | difference: difference, 113 | sulphateChlorideRatio: adjustedWater.SO4 / adjustedWater.Cl, 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /tests/abv.test.ts: -------------------------------------------------------------------------------- 1 | import { recipe as AussieAle } from './data/AussieAle' 2 | import { calcABV } from '../src/abv' 3 | 4 | describe('ABV', () => { 5 | test('Calc ABV', () => { 6 | const OG = AussieAle.original_gravity 7 | const FG = AussieAle.final_gravity 8 | const AussieAleABV = calcABV(OG, FG) 9 | 10 | expect(AussieAleABV.value).toBeCloseTo(4.7, 1) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /tests/bitterness.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bitternessIbuTinseth, 3 | bitternessRatio, 4 | bitternessIbuRager, 5 | } from '../src' 6 | import { calcBoilGravity, calcOriginalGravity } from '../src' 7 | import { recipe as AussieAle } from './data/AussieAle' 8 | import { recipe as MuddyPig } from './data/MuddyPig' 9 | import TestRecipe from './data/TestRecipeConverted' 10 | import Equipment from './data/TestEquipment' 11 | 12 | import { GravityType, VolumeType } from '../src/types/beerjson' 13 | 14 | const calcRecipeBoilGravity = (recipe) => 15 | calcBoilGravity( 16 | recipe.batch_size, 17 | recipe.boil.pre_boil_size, 18 | calcOriginalGravity( 19 | recipe.batch_size, 20 | recipe.ingredients.fermentable_additions, 21 | recipe.efficiency 22 | ) 23 | ) 24 | 25 | const calcPostBoilVolume = (recipe, equipment): VolumeType => ({ 26 | value: 27 | recipe.boil.pre_boil_size.value - 28 | (equipment.boil_rate_per_hour.value * recipe.boil.boil_time.value) / 60, 29 | unit: 'gal', 30 | }) 31 | 32 | test('Calc bitterness by Tinseth', () => { 33 | const ibu = bitternessIbuTinseth( 34 | TestRecipe.ingredients.hop_additions, 35 | calcRecipeBoilGravity(TestRecipe), 36 | calcPostBoilVolume(TestRecipe, Equipment) 37 | ) 38 | 39 | expect(ibu.value).toBeCloseTo(22, 0) 40 | expect(ibu.unit).toEqual('IBUs') 41 | }) 42 | 43 | test('Calc bitterness ratio', () => { 44 | const og = calcOriginalGravity( 45 | TestRecipe.batch_size, 46 | TestRecipe.ingredients.fermentable_additions, 47 | TestRecipe.efficiency 48 | ) 49 | 50 | const ibu = bitternessIbuTinseth( 51 | TestRecipe.ingredients.hop_additions, 52 | calcRecipeBoilGravity(TestRecipe), 53 | calcPostBoilVolume(TestRecipe, Equipment) 54 | ) 55 | 56 | const gu = (og.value - 1) * 1000 57 | 58 | expect(bitternessRatio(ibu.value, gu)).toBeCloseTo(0.64, 0) 59 | }) 60 | 61 | test('Calc bitterness by Rager', () => { 62 | const ibu = bitternessIbuRager( 63 | TestRecipe.ingredients.hop_additions, 64 | calcRecipeBoilGravity(TestRecipe), 65 | calcPostBoilVolume(TestRecipe, Equipment) 66 | ) 67 | 68 | expect(ibu.value).toBeCloseTo(25, 0) 69 | expect(ibu.unit).toEqual('IBUs') 70 | }) 71 | 72 | test('Calc bitterness by Rager: sgb > 1.05', () => { 73 | const boilGravity: GravityType = { value: 1.06, unit: 'sg' } 74 | const ibu = bitternessIbuRager( 75 | TestRecipe.ingredients.hop_additions, 76 | boilGravity, 77 | calcPostBoilVolume(TestRecipe, Equipment) 78 | ) 79 | expect(ibu.value).toBeCloseTo(24, 0) 80 | }) 81 | 82 | test.skip('bitternessIbuTinseth: old', () => { 83 | const AussieAleIBU = bitternessIbuTinseth( 84 | AussieAle.ingredients.hop_additions, 85 | calcRecipeBoilGravity(AussieAle), 86 | AussieAle.batch_size 87 | ) 88 | 89 | expect(AussieAleIBU.value).toBeCloseTo(28, 0) 90 | expect(AussieAleIBU.unit).toEqual('IBUs') 91 | 92 | const MuddyPigIBU = bitternessIbuTinseth( 93 | MuddyPig.ingredients.hop_additions, 94 | calcRecipeBoilGravity(MuddyPig), 95 | MuddyPig.batch_size 96 | ) 97 | 98 | expect(MuddyPigIBU.value).toBeCloseTo(31.7, 0) 99 | expect(MuddyPigIBU.unit).toEqual('IBUs') 100 | }) 101 | 102 | test.skip('bitternessRatio: old', () => { 103 | const ibu = bitternessIbuTinseth( 104 | AussieAle.ingredients.hop_additions, 105 | calcRecipeBoilGravity(AussieAle), 106 | AussieAle.batch_size 107 | ) 108 | 109 | const gu = (AussieAle.original_gravity.value - 1) * 1000 110 | 111 | expect(bitternessRatio(ibu.value, gu)).toBeCloseTo(0.64, 2) 112 | }) 113 | 114 | //TODO: Rager IBU calc test 115 | /*test('bitternessIbuRager: old', () => { 116 | const ibu = bitternessIbuRager( 117 | TestRecipe.ingredients.hop_additions, 118 | calcRecipeBoilGravity(Recipe), 119 | calcPostBoilVolume(Recipe, Equipment) 120 | ) 121 | 122 | expect(ibu.value).toBeCloseTo(25, 0) 123 | expect(ibu.unit).toEqual('IBUs') 124 | }) 125 | 126 | test('bitternessIbuRager when sgb > 1.05', () => { 127 | const boilGravity = { value: 1.06, unit: 'sg' } 128 | const ibu = bitternessIbuRager( 129 | TestRecipe.ingredients.hop_additions, 130 | boilGravity, 131 | calcPostBoilVolume(Recipe, Equipment) 132 | ) 133 | expect(ibu.value).toBeCloseTo(24, 0) 134 | })*/ 135 | -------------------------------------------------------------------------------- /tests/brewcalc.test.ts: -------------------------------------------------------------------------------- 1 | import { yeastNeeded, yeastCount, yeastStarterGrow } from '../src/culture' 2 | import { carbonation, calcCalories } from '../src/carbonation' 3 | 4 | import { sgToPlato, kpaToPsi } from '../src/utils' 5 | 6 | import type { Yeast } from '../src/types/yeast' 7 | import { YeastTypes, YeastForms } from '../src/types/yeast' 8 | 9 | declare var test: any 10 | declare var expect: any 11 | 12 | test('yeastNeeded, yeastCount, yeastStarterGrow', () => { 13 | const yeast: Yeast = { 14 | name: 'German Ale', 15 | amount: 0.011, 16 | attenuation: 0, 17 | type: YeastTypes.ale, 18 | form: YeastForms.dry, 19 | cultureDate: '2017-03-06', 20 | } 21 | 22 | const batchSize = 20.82 23 | const pitchRate = yeast.type === YeastTypes.ale ? 0.75 : 1.5 24 | const gravity = 1.04 25 | const starterSize = 1 26 | 27 | expect(yeastNeeded(pitchRate, batchSize, sgToPlato(gravity))).toBeCloseTo( 28 | 156, 29 | 0 30 | ) 31 | expect(yeastCount({ ...yeast })).toBeCloseTo(88, 1) 32 | expect( 33 | yeastCount({ ...yeast, form: YeastForms.liquid, amount: 1 }, '2017-06-14') 34 | ).toBeCloseTo(30, 1) 35 | expect( 36 | yeastCount({ ...yeast, form: YeastForms.liquid, amount: 1 }, '2017-03-06') 37 | ).toBeCloseTo(100, 1) 38 | expect( 39 | yeastCount({ ...yeast, form: YeastForms.slant, amount: 1 }) 40 | ).toBeCloseTo(1000, 1) 41 | 42 | expect(() => { 43 | yeastCount({ ...yeast, form: YeastForms.culture, amount: 1 }) 44 | }).toThrow() 45 | 46 | expect( 47 | yeastStarterGrow(88, starterSize, gravity, batchSize).growthRate 48 | ).toBeCloseTo(1.4, 1) 49 | 50 | expect( 51 | yeastStarterGrow(88, starterSize, gravity, batchSize).endingCount 52 | ).toBeCloseTo(248, 0) 53 | 54 | expect( 55 | yeastStarterGrow(88, starterSize, gravity, batchSize).pitchRate 56 | ).toBeCloseTo(1191038, 0) 57 | 58 | expect( 59 | yeastStarterGrow(188, starterSize, gravity, batchSize).growthRate 60 | ).toBeCloseTo(1.2, 1) 61 | 62 | expect( 63 | yeastStarterGrow(188, starterSize, gravity, batchSize).endingCount 64 | ).toBeCloseTo(328, 0) 65 | 66 | expect( 67 | yeastStarterGrow(188, starterSize, gravity, batchSize).pitchRate 68 | ).toBeCloseTo(1576504, 0) 69 | 70 | expect( 71 | yeastStarterGrow(488, starterSize, gravity, batchSize).growthRate 72 | ).toBeCloseTo(0, 1) 73 | 74 | expect( 75 | yeastStarterGrow(488, starterSize, gravity, batchSize).endingCount 76 | ).toBeCloseTo(488, 0) 77 | 78 | expect( 79 | yeastStarterGrow(488, starterSize, gravity, batchSize).pitchRate 80 | ).toBeCloseTo(2345417, 0) 81 | }) 82 | 83 | test('carbonation', () => { 84 | const carbVolume = 2.4 85 | const t = 4.4 86 | const batchSize = 18.93 87 | 88 | expect(carbonation(carbVolume, t, batchSize).kegPressure).toBeCloseTo( 89 | kpaToPsi(77.15), 90 | 1 91 | ) 92 | 93 | expect(carbonation(carbVolume, t, batchSize).kegSugar).toBeCloseTo(35.7, 0) 94 | expect(carbonation(carbVolume, t, batchSize).cornSugar).toBeCloseTo(71.4, 0) 95 | expect(carbonation(carbVolume, t, batchSize).dme).toBeCloseTo(109.82, 0) 96 | }) 97 | 98 | test('calcCalories', () => { 99 | expect(calcCalories(1.044, 1.008)).toBeCloseTo(143.874, 3) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/color.test.ts: -------------------------------------------------------------------------------- 1 | import { recipe as AussieAle } from './data/AussieAle' 2 | import { calcColor, srmToCss } from '../src/color' 3 | 4 | describe('Color', () => { 5 | test('Calc color SRM', () => { 6 | // http://beersmith.com/blog/2008/04/29/beer-color-understanding-srm-lovibond-and-ebc/ 7 | const colorSRM = calcColor( 8 | AussieAle.ingredients.fermentable_additions, 9 | AussieAle.batch_size 10 | ) 11 | 12 | const colorEBCvalue = 1.97 * colorSRM.value 13 | 14 | // 8.6 15 | expect(colorSRM.value).toBeCloseTo(14.7, 1) 16 | // 16.8 17 | expect(colorEBCvalue).toBeCloseTo(29, 1) 18 | }) 19 | 20 | test('Convert color SRM to css string', () => { 21 | const cssColor = srmToCss(19.5) 22 | expect(cssColor).toEqual('rgb(156, 21, 0)') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/converter.test.ts: -------------------------------------------------------------------------------- 1 | import { convert } from '../src' 2 | 3 | describe('Converter', () => { 4 | test('convert inside the system', () => { 5 | expect(convert(1000, 'ml', 'l')).toBeCloseTo(1, 0) 6 | expect(convert(1, 'hr', 'min')).toBeCloseTo(60, 0) 7 | }) 8 | 9 | test('convert from one system to another', () => { 10 | expect(convert(1, 'kg', 'lb')).toBeCloseTo(2.2) 11 | expect(convert(1, 'l', 'qt')).toBeCloseTo(1.06) 12 | }) 13 | 14 | test('convert gravity', () => { 15 | expect(convert(5.0, 'plato', 'sg')).toBeCloseTo(1.02) 16 | expect(convert(12.5, 'plato', 'sg')).toBeCloseTo(1.05) 17 | expect(convert(35, 'plato', 'sg')).toBeCloseTo(1.154) 18 | 19 | expect(convert(1.008, 'sg', 'plato')).toBeCloseTo(2, 1) 20 | expect(convert(1.034, 'sg', 'plato')).toBeCloseTo(8.5, 1) 21 | expect(convert(1.149, 'sg', 'plato')).toBeCloseTo(34, 1) 22 | }) 23 | 24 | test('convert temperature', () => { 25 | expect(convert(50, 'C', 'F')).toBeCloseTo(122) 26 | expect(convert(122, 'F', 'C')).toBeCloseTo(50) 27 | }) 28 | 29 | test('convert volumes', () => { 30 | expect(convert(10, 'l', 'igal')).toBeCloseTo(2.2, 1) 31 | expect(convert(10, 'l', 'gal')).toBeCloseTo(2.6, 1) 32 | expect(convert(10, 'l', 'ifloz')).toBeCloseTo(351.95, 2) 33 | }) 34 | 35 | test('convert pressure', () => { 36 | expect(convert(10, 'psi', 'kPa')).toBeCloseTo(68.9, 1) 37 | expect(convert(10, 'bar', 'atm')).toBeCloseTo(9.9, 1) 38 | }) 39 | 40 | test('convert temperature to mass: error', () => { 41 | expect(() => convert(50, 'C', 'kg')).toThrow() 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /tests/data/AussieAle.ts: -------------------------------------------------------------------------------- 1 | import type { RecipeType, EquipmentType } from '../../src/types/beerjson' 2 | 3 | export const recipe: RecipeType = { 4 | name: 'Aussie Ale', 5 | type: 'all grain', 6 | author: 'Steve Nicholls', 7 | coauthor: '', 8 | alcohol_by_volume: { 9 | value: 4.7, 10 | unit: '%', 11 | }, 12 | original_gravity: { 13 | value: 1.044, 14 | unit: 'sg', 15 | }, 16 | final_gravity: { 17 | value: 1.008, 18 | unit: 'sg', 19 | }, 20 | batch_size: { 21 | value: 23.02, 22 | unit: 'l', 23 | }, 24 | boil: { 25 | pre_boil_size: { 26 | value: 37.1203164, 27 | unit: 'l', 28 | }, 29 | boil_time: { value: 90, unit: 'min' }, 30 | }, 31 | efficiency: { brewhouse: { value: 68, unit: '%' } }, 32 | ingredients: { 33 | fermentable_additions: [ 34 | { 35 | name: 'Pale Malt (2 Row) UK', 36 | type: 'grain', 37 | amount: { value: 4.4969891, unit: 'kg' }, 38 | yield: { 39 | fine_grind: { value: 65, unit: '%' }, 40 | fine_coarse_difference: { value: 1.5, unit: '%' }, 41 | }, 42 | color: { value: 4.925, unit: 'Lovi' }, 43 | origin: 'United Kingdom', 44 | moisture: '4.0000000', 45 | diastaticPower: '45.0000000', 46 | protein: '10.1000000', 47 | maxInBatch: '100.0000000', 48 | recommendMash: false, 49 | ibuGalPerLb: '0.0000000', 50 | }, 51 | { 52 | name: 'Munich Malt - 20L', 53 | type: 'grain', 54 | amount: { value: 518.8834, unit: 'g' }, 55 | yield: { fine_grind: { value: 75, unit: '%' } }, 56 | color: { value: 39.4, unit: 'Lovi' }, 57 | origin: 'US', 58 | moisture: '5.0000000', 59 | diastaticPower: '25.0000000', 60 | protein: '13.5000000', 61 | maxInBatch: '80.0000000', 62 | recommendMash: true, 63 | ibuGalPerLb: '0.0000000', 64 | }, 65 | { 66 | name: 'Caramel/Crystal Malt - 20L', 67 | type: 'grain', 68 | amount: { value: 0.201788, unit: 'kg' }, 69 | yield: { coarse_grind: { value: 75, unit: '%' } }, 70 | color: { value: 39.4, unit: 'Lovi' }, 71 | origin: 'US', 72 | moisture: '4.0000000', 73 | diastaticPower: '0.0000000', 74 | protein: '13.2000000', 75 | maxInBatch: '20.0000000', 76 | recommendMash: false, 77 | ibuGalPerLb: '0.0000000', 78 | }, 79 | { 80 | name: 'Roasted Barley', 81 | type: 'grain', 82 | amount: { value: 46.123, unit: 'g' }, 83 | yield: { potential: { value: 1.0253, unit: 'sg' } }, 84 | color: { value: 591.0, unit: 'Lovi' }, 85 | origin: 'US', 86 | moisture: '5.0000000', 87 | diastaticPower: '0.0000000', 88 | protein: '13.2000000', 89 | maxInBatch: '10.0000000', 90 | recommendMash: false, 91 | ibuGalPerLb: '0.0000000', 92 | }, 93 | { 94 | name: 'Cane (Beet) Sugar', 95 | type: 'sugar', 96 | amount: { value: 0.57621, unit: 'lb' }, 97 | yield: { potential: { value: 1.046, unit: 'sg' } }, 98 | color: { value: 0, unit: 'Lovi' }, 99 | origin: 'US', 100 | moisture: '4.0000000', 101 | diastaticPower: '120.0000000', 102 | protein: '11.7000000', 103 | maxInBatch: '7.0000000', 104 | recommendMash: false, 105 | ibuGalPerLb: '0.0000000', 106 | }, 107 | ], 108 | hop_additions: [ 109 | { 110 | name: 'Pride of Ringwood', 111 | origin: 'Australia', 112 | alpha_acid: { 113 | value: 10, 114 | unit: '%', 115 | }, 116 | amount: { value: 5.2055, unit: 'g' }, 117 | timing: { 118 | // TODO: first wort??? 119 | use: 'add_to_boil', 120 | time: { value: 60, unit: 'min' }, 121 | }, 122 | form: 'leaf', 123 | beta_acid: { 124 | value: 5.8, 125 | unit: '%', 126 | }, 127 | }, 128 | { 129 | name: 'Pride of Ringwood', 130 | origin: 'Australia', 131 | alpha_acid: { 132 | value: 10, 133 | unit: '%', 134 | }, 135 | amount: { value: 5.2055, unit: 'g' }, 136 | timing: { 137 | use: 'add_to_boil', 138 | time: { value: 45, unit: 'min' }, 139 | }, 140 | form: 'pellet', 141 | beta_acid: { 142 | value: 5.8, 143 | unit: '%', 144 | }, 145 | }, 146 | { 147 | name: 'Pride of Ringwood', 148 | origin: 'Australia', 149 | alpha_acid: { 150 | value: 10, 151 | unit: '%', 152 | }, 153 | amount: { value: 31.2328, unit: 'g' }, 154 | timing: { 155 | use: 'add_to_boil', 156 | time: { value: 15, unit: 'min' }, 157 | }, 158 | form: 'leaf', 159 | beta_acid: { 160 | value: 5.8, 161 | unit: '%', 162 | }, 163 | }, 164 | { 165 | name: 'Pride of Ringwood', 166 | origin: 'Australia', 167 | alpha_acid: { 168 | value: 10, 169 | unit: '%', 170 | }, 171 | amount: { value: 15.6818, unit: 'g' }, 172 | timing: { 173 | use: 'add_to_boil', 174 | time: { value: 0, unit: 'min' }, 175 | }, 176 | form: 'leaf', 177 | beta_acid: { 178 | value: 5.8, 179 | unit: '%', 180 | }, 181 | }, 182 | ], 183 | culture_additions: [ 184 | { 185 | name: 'American Ale', 186 | type: 'ale', 187 | form: 'liquid', 188 | amount: { value: 0.125048, unit: 'l' }, 189 | amountIsWeight: false, 190 | laboratory: 'Wyeast Labs', 191 | productId: '1056', 192 | minTemperature: '15.5555556', 193 | maxTemperature: '22.2222222', 194 | flocculation: 'Medium', 195 | attenuation: { value: 75, unit: '%' }, 196 | notes: 197 | 'Soft, smooth, clean finish. Very well balanced. Very versitile -- works well with many ale styles.', 198 | bestFor: 199 | 'American Pale Ale, Scottish Ale, Porters, Sweet Stout, Barley Wine, Alt', 200 | maxReuse: '5', 201 | timesCultured: '0', 202 | addToSecondary: false, 203 | displayAmount: '125.05 ml', 204 | dispMinTemp: '15.6 C', 205 | dispMaxTemp: '22.2 C', 206 | inventory: '0.0 pkg', 207 | cultureDate: '18 Jun 2003', 208 | }, 209 | ], 210 | }, 211 | style: { 212 | name: 'Australian Ale', 213 | version: '1', 214 | category: 'Ale', 215 | categoryNumber: '1', 216 | styleLetter: 'A', 217 | styleGuide: '', 218 | type: 'Ale', 219 | ogMin: '1.0350000', 220 | ogMax: '1.0600000', 221 | fgMin: '1.0080000', 222 | fgMax: '1.0150000', 223 | ibuMin: '10.0000000', 224 | ibuMax: '30.0000000', 225 | colorMin: '3.9400000', 226 | colorMax: '19.7000000', 227 | carbMin: '2.2000000', 228 | carbMax: '2.8000000', 229 | abvMax: '5.0000000', 230 | abvMin: '2.0000000', 231 | notes: 'Medium malt with slight grain dryness', 232 | profile: '', 233 | ingredients: 'Pride of Ringwood hops.', 234 | examples: 'Cooper's Pale Ale', 235 | displayOgMin: '1.035 SG', 236 | displayOgMax: '1.060 SG', 237 | displayFgMin: '1.008 SG', 238 | displayFgMax: '1.015 SG', 239 | displayColorMin: '3.9 EBC', 240 | displayColorMax: '19.7 EBC', 241 | ogRange: '1.035-1.060 SG', 242 | fgRange: '1.008-1.015 SG', 243 | ibuRange: '10.0-30.0 IBUs', 244 | carbRange: '2.20-2.80 Vols', 245 | colorRange: '3.9-19.7 EBC', 246 | abvRange: '2.00-5.00 %', 247 | }, 248 | mash: { 249 | name: 'Single Infusion, Medium Body', 250 | grain_temperature: { 251 | value: 22.22, 252 | unit: 'C', 253 | }, 254 | spargeTemp: 75.5555556, 255 | ph: '5.4000000', 256 | tunWeight: '144.0000000', 257 | tunSpecificHeat: '0.3000000', 258 | equipAdjust: true, 259 | notes: 260 | 'Simple single infusion mash for use with most modern well modified grains (about 95% of the time).', 261 | displayGrainTemp: '22.2 C', 262 | displayTunTemp: '22.2 C', 263 | displaySpargeTemp: '75.6 C', 264 | displayTunWeight: '4.08 kg', 265 | mash_steps: [ 266 | { 267 | name: 'Mash In', 268 | type: 'infusion', 269 | infuse_temperature: { 270 | value: 74.1, 271 | unit: 'C', 272 | }, 273 | amount: { 274 | value: 16.76, 275 | //value: 13.7276426, 276 | unit: 'l', 277 | }, 278 | step_time: { 279 | value: 60, 280 | unit: 'min', 281 | }, 282 | step_temperature: { 283 | value: 66.67, 284 | unit: 'C', 285 | }, 286 | }, 287 | { 288 | name: 'Mash Out', 289 | type: 'infusion', 290 | infuse_temperature: { 291 | value: 98.5, 292 | unit: 'C', 293 | }, 294 | amount: { 295 | value: 7.6874799, 296 | unit: 'l', 297 | }, 298 | step_time: { 299 | value: 10, 300 | unit: 'min', 301 | }, 302 | step_temperature: { 303 | value: 75.55, 304 | unit: 'C', 305 | }, 306 | }, 307 | ], 308 | 309 | mashSteps: [ 310 | { 311 | name: 'Mash In', 312 | version: '1', 313 | type: 'Infusion', 314 | infuseAmount: 13.7276426, 315 | stepTime: 60.0, 316 | stepTemp: 66.6666667, 317 | rampTime: 2.0, 318 | endTemp: 66.6666667, 319 | description: 'Add 16.76 l of water at 74.1 C', 320 | waterGrainRatio: '2.608 qt/lb', 321 | decoctionAmt: '0.00 l', 322 | infuseTemp: '74.1 C', 323 | displayStepTemp: '66.7 C', 324 | displayInfuseAmt: '16.76 l', 325 | }, 326 | { 327 | name: 'Mash Out', 328 | version: '1', 329 | type: 'Infusion', 330 | infuseAmount: 7.6874799, 331 | stepTime: 10.0, 332 | stepTemp: 75.5555556, 333 | rampTime: 2.0, 334 | endTemp: 75.5555556, 335 | description: 'Add 7.69 l of water at 98.5 C', 336 | waterGrainRatio: '4.644 qt/lb', 337 | decoctionAmt: '0.00 l', 338 | infuseTemp: '98.5 C', 339 | displayStepTemp: '75.6 C', 340 | displayInfuseAmt: '7.69 l', 341 | }, 342 | ], 343 | }, 344 | notes: 345 | 'FWH the first hop addition.\r\nAllow last addition to sit for 5 minutes to release aroma.', 346 | tasteNotes: 347 | 'Very similar to Australian beers in the 60's. Pride of Ringwood is the traditional hop used for a very large number of Australian beers. Although not considered a typical flavour hop it works very well as a single hopped beer. Aim for 50 - 100 ppm of C', 348 | tasteRating: '41.0000000', 349 | og: 1.044, 350 | fg: 1.008, 351 | carbonation: '2.4000000', 352 | fermentationStages: '2', 353 | primaryAge: '4.0000000', 354 | primaryTemp: '19.4444444', 355 | secondaryAge: '10.0000000', 356 | secondaryTemp: '19.4444444', 357 | tertiaryAge: '7.0000000', 358 | age: '30.0000000', 359 | ageTemp: '18.3333333', 360 | carbonationUsed: 'Bottle with 133.96 g Corn Sugar', 361 | forcedCarbonation: true, 362 | primingSugarName: 'Corn Sugar', 363 | primingSugarEquiv: '1.0000000', 364 | kegPrimingFactor: '0.5000000', 365 | carbonationTemp: '21.1111111', 366 | displayCarbTemp: '21.1 C', 367 | date: '14 May 2011', 368 | estOg: '1.044 SG', 369 | estFg: '1.008 SG', 370 | estColor: '16.8 EBC', 371 | ibu: '28.0 IBUs', 372 | ibuMethod: 'Tinseth', 373 | estAbv: '4.7 %', 374 | abv: '4.7 %', 375 | actualEfficiency: '68.0 %', 376 | calories: '405.4 kcal/l', 377 | displayBatchSize: '23.02 l', 378 | displayBoilSize: '37.12 l', 379 | displayOg: '1.044 SG', 380 | displayFg: '1.008 SG', 381 | displayPrimaryTemp: '19.4 C', 382 | displaySecondaryTemp: '19.4 C', 383 | displayTertiaryTemp: '18.3 C', 384 | displayAgeTemp: '18.3 C', 385 | } 386 | 387 | export const equipment: EquipmentType = { 388 | name: 'Pot (18.5 Gal/70 L) and Cooler (9.5 Gal/40 L) - All Grain', 389 | equipment_items: [ 390 | { 391 | name: 'Mash Tun', 392 | form: 'Mash Tun', 393 | maximum_volume: { value: 40, unit: 'l' }, 394 | // lauter dead space 395 | loss: { value: 3.03, unit: 'l' }, 396 | }, 397 | { 398 | name: 'Brew Kettle', 399 | form: 'Brew Kettle', 400 | maximum_volume: { value: 70, unit: 'l' }, 401 | boil_rate_per_hour: { 402 | value: 6.9, 403 | unit: 'l', 404 | }, 405 | // trub/chiller loss 406 | loss: { value: 2.84, unit: 'l' }, 407 | }, 408 | ], 409 | 410 | boilSize: 37.1203164, 411 | batchSize: 23.02, 412 | tunVolume: 37.85, 413 | tunWeight: 4.082328, 414 | tunSpecificHeat: 0.3, 415 | topUpWater: '0.0000000', 416 | trubChillerLoss: 2.84, 417 | evapRate: 0.182879483, 418 | boilTime: 90.0, 419 | calcBoilVolume: true, 420 | lauterDeadspace: 3.03, 421 | topUpKettle: 0.0, 422 | hopUtilization: '100.0000000', 423 | coolingLossPct: 0.04, 424 | notes: 425 | 'Based on a 18.5 Gal/70 L pot with a diameter of 18 inches/45 cm and a 9.5 Gal/40 L cooler. The above assumes loose pellet hops and only clear, chilled wort transferred from the kettle using no trub management techniques. Experienced brewers should adjust 'Loss to Trub and Chiller' and 'Brewhouse Efficiency' accordingly to suit their trub management techniques.', 426 | displayBoilSize: '37.12 l', 427 | displayBatchSize: '23.02 l', 428 | displayTunVolume: '37.85 l', 429 | displayTunWeight: '4.08 kg', 430 | displayTopUpWater: '0.00 l', 431 | displayTrubChillerLoss: '2.84 l', 432 | displayLauterDeadspace: '3.03 l', 433 | displayTopUpKettle: '0.00 l', 434 | BIAB: false, 435 | } 436 | -------------------------------------------------------------------------------- /tests/data/BrewcalcTest.json: -------------------------------------------------------------------------------- 1 | { 2 | "beerjson": { 3 | "version": 2.06, 4 | "recipes": [ 5 | { 6 | "name": "BrewcalcTest", 7 | "type": "all grain", 8 | "author": "Brewcalc", 9 | "created": "2018-12-25T21:00:00.000Z", 10 | "batch_size": { "unit": "l", "value": 18.9270589 }, 11 | "boil": { 12 | "pre_boil_size": { "unit": "l", "value": 24.7171617 }, 13 | "boil_time": { "unit": "min", "value": 60 } 14 | }, 15 | "efficiency": { "brewhouse": { "unit": "%", "value": 60 } }, 16 | "style": { 17 | "name": "Blonde Ale", 18 | "category": "Pale American Ale", 19 | "style_guide": "BJCP 2015", 20 | "type": "beer" 21 | }, 22 | "ingredients": { 23 | "fermentable_additions": [ 24 | { 25 | "name": "Pale Malt, Maris Otter", 26 | "type": "grain", 27 | "color": { "unit": "SRM", "value": 3 }, 28 | "amount": { "unit": "kg", "value": 4.2184056 }, 29 | "origin": "United Kingdom", 30 | "supplier": "Maris Otter", 31 | "group": "base", 32 | "yield": { "potential": { "unit": "sg", "value": 1.03795 } }, 33 | "add_after_boil": false 34 | }, 35 | { 36 | "name": "Caramel/Crystal Malt - 10L", 37 | "type": "grain", 38 | "color": { "unit": "SRM", "value": 10 }, 39 | "amount": { "unit": "kg", "value": 0.226796 }, 40 | "origin": "US", 41 | "supplier": "", 42 | "group": "base", 43 | "yield": { "potential": { "unit": "sg", "value": 1.0345 } }, 44 | "add_after_boil": false 45 | } 46 | ], 47 | "hop_additions": [ 48 | { 49 | "name": "Tradition", 50 | "alpha_acid": { "unit": "%", "value": 6 }, 51 | "origin": "Germany", 52 | "form": "pellet", 53 | "beta_acid": { "unit": "%", "value": 4.5 }, 54 | "use": "boil", 55 | "amount": { "unit": "kg", "value": 0.0283495 }, 56 | "timing": { 57 | "time": { "unit": "min", "value": 60 }, 58 | "use": "add_to_boil" 59 | } 60 | }, 61 | { 62 | "name": "Citra", 63 | "alpha_acid": { "unit": "%", "value": 12 }, 64 | "origin": "U.S.", 65 | "form": "leaf", 66 | "beta_acid": { "unit": "%", "value": 4 }, 67 | "use": "dry hop", 68 | "amount": { "unit": "kg", "value": 0.0141747 }, 69 | "timing": { 70 | "time": { "unit": "min", "value": 2880 }, 71 | "use": "add_to_fermentation" 72 | } 73 | } 74 | ], 75 | "culture_additions": [ 76 | { 77 | "name": "US West Coast", 78 | "attenuation": { "unit": "%", "value": 81 }, 79 | "type": "ale", 80 | "form": "dry", 81 | "amount": { "unit": "l", "value": 0 } 82 | } 83 | ] 84 | }, 85 | "mash": { 86 | "name": "Single Infusion, Full Body", 87 | "grain_temperature": { "unit": "C", "value": 22.2222222 }, 88 | "sparge_temperature": { "unit": "C", "value": 75.5555556 }, 89 | "pH": { "unit": "pH", "value": 5.4 }, 90 | "notes": "Simple single infusion mash for use with most modern well modified grains (about 95% of the time).", 91 | "mash_steps": [ 92 | { 93 | "name": "Mash In", 94 | "type": "infusion", 95 | "step_temperature": { "unit": "C", "value": 68.8888889 }, 96 | "step_time": { "unit": "min", "value": 45 }, 97 | "ramp_time": { "unit": "min", "value": 2 }, 98 | "end_temperature": { "unit": "C", "value": 68.8888889 }, 99 | "amount": { "unit": "l", "value": 11.5928236 } 100 | }, 101 | { 102 | "name": "Mash Out", 103 | "type": "infusion", 104 | "step_temperature": { "unit": "C", "value": 75.5555556 }, 105 | "step_time": { "unit": "min", "value": 10 }, 106 | "ramp_time": { "unit": "min", "value": 2 }, 107 | "end_temperature": { "unit": "C", "value": 75.5555556 }, 108 | "amount": { "unit": "l", "value": 4.6371294 } 109 | } 110 | ] 111 | } 112 | } 113 | ] 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/data/MuddyPig.ts: -------------------------------------------------------------------------------- 1 | import type { RecipeType } from '../../src/types/beerjson' 2 | 3 | export const recipe: RecipeType = { 4 | name: 'Muddy Pig Oatmeal Stout', 5 | type: 'extract', 6 | author: 'Brian Smith', 7 | coauthor: '', 8 | alcohol_by_volume: { 9 | value: 6.3, 10 | unit: '%', 11 | }, 12 | original_gravity: { 13 | value: 1.063, 14 | unit: 'sg', 15 | }, 16 | final_gravity: { 17 | value: 1.015, 18 | unit: 'sg', 19 | }, 20 | batch_size: { value: 18.93, unit: 'l' }, 21 | boilSize: 14.81, 22 | boilTime: 45.0, 23 | boil: { 24 | pre_boil_size: { 25 | value: 14.81, 26 | unit: 'l', 27 | }, 28 | boil_time: { value: 45, unit: 'min' }, 29 | }, 30 | efficiency: { brewhouse: { value: 15, unit: '%' } }, 31 | ingredients: { 32 | fermentable_additions: [ 33 | { 34 | name: 'Caramel/Crystal Malt - 60L', 35 | type: 'grain', 36 | amount: { value: 1, unit: 'lb' }, 37 | yield: { 38 | potential: { value: 1.03404, unit: 'sg' }, 39 | fine_grind: { value: 74, unit: '%' }, 40 | fine_coarse_difference: { value: 1.5, unit: '%' }, 41 | }, 42 | color: { value: 60, unit: 'SRM' }, 43 | origin: 'US', 44 | moisture: '4.0000000', 45 | diastaticPower: '0.0000000', 46 | protein: '13.2000000', 47 | maxInBatch: '20.0000000', 48 | recommendMash: false, 49 | ibuGalPerLb: '0.0000000', 50 | displayAmount: '1 lbs', 51 | inventory: '1 lbs', 52 | potential: 1.03404, 53 | displayColor: '60.0 SRM', 54 | extractSubstitute: '', 55 | }, 56 | { 57 | name: 'Roasted Barley', 58 | type: 'grain', 59 | amount: { value: 8, unit: 'oz' }, 60 | yield: { 61 | potential: { value: 1.0253, unit: 'sg' }, 62 | fine_grind: { value: 55, unit: '%' }, 63 | fine_coarse_difference: { value: 1.5, unit: '%' }, 64 | }, 65 | color: { value: 300, unit: 'SRM' }, 66 | origin: 'US', 67 | moisture: '5.0000000', 68 | diastaticPower: '0.0000000', 69 | protein: '13.2000000', 70 | maxInBatch: '10.0000000', 71 | recommendMash: false, 72 | ibuGalPerLb: '0.0000000', 73 | displayAmount: '8.0 oz', 74 | inventory: '8.0 oz', 75 | potential: 1.0253, 76 | displayColor: '300.0 SRM', 77 | extractSubstitute: '', 78 | }, 79 | { 80 | name: 'Amber Dry Extract', 81 | type: 'dry extract', 82 | amount: { value: 2, unit: 'lb' }, 83 | yield: { 84 | potential: { value: 1.0437, unit: 'sg' }, 85 | fine_grind: { value: 95, unit: '%' }, 86 | fine_coarse_difference: { value: 1.5, unit: '%' }, 87 | }, 88 | color: { value: 12.5, unit: 'SRM' }, 89 | origin: 'US', 90 | moisture: '4.0000000', 91 | diastaticPower: '120.0000000', 92 | protein: '11.7000000', 93 | maxInBatch: '100.0000000', 94 | recommendMash: false, 95 | ibuGalPerLb: '0.0000000', 96 | displayAmount: '2 lbs', 97 | inventory: '2 lbs', 98 | potential: 1.0437, 99 | displayColor: '12.5 SRM', 100 | extractSubstitute: '', 101 | }, 102 | { 103 | name: 'Dark Liquid Extract', 104 | type: 'extract', 105 | amount: { value: 2.7669112, unit: 'kg' }, 106 | yield: { 107 | potential: { value: 1.03588, unit: 'sg' }, 108 | fine_grind: { value: 78, unit: '%' }, 109 | fine_coarse_difference: { value: 1.5, unit: '%' }, 110 | }, 111 | color: { value: 17.5, unit: 'SRM' }, 112 | origin: 'US', 113 | moisture: '4.0000000', 114 | diastaticPower: '120.0000000', 115 | protein: '11.7000000', 116 | maxInBatch: '100.0000000', 117 | recommendMash: false, 118 | ibuGalPerLb: '0.0000000', 119 | displayAmount: '6 lbs 1.6 oz', 120 | inventory: '6 lbs 9.6 oz', 121 | potential: 1.03588, 122 | displayColor: '17.5 SRM', 123 | extractSubstitute: '', 124 | }, 125 | ], 126 | hop_additions: [ 127 | { 128 | name: 'Cluster', 129 | origin: 'US', 130 | alpha_acid: { value: 7, unit: '%' }, 131 | amount: { value: 1, unit: 'oz' }, 132 | timing: { 133 | use: 'add_to_boil', 134 | time: { value: 45, unit: 'min' }, 135 | }, 136 | form: 'pellet', 137 | beta_acid: { 138 | value: 4.8, 139 | unit: '%', 140 | }, 141 | }, 142 | { 143 | name: 'Williamette', 144 | origin: 'US', 145 | alpha_acid: { value: 4.8, unit: '%' }, 146 | amount: { value: 1, unit: 'oz' }, 147 | timing: { 148 | use: 'add_to_boil', 149 | time: { value: 45, unit: 'min' }, 150 | }, 151 | form: 'pellet', 152 | beta_acid: { 153 | value: 3.5, 154 | unit: '%', 155 | }, 156 | }, 157 | { 158 | name: 'Williamette', 159 | origin: 'US', 160 | alpha_acid: { value: 4.8, unit: '%' }, 161 | amount: { value: 1, unit: 'oz' }, 162 | timing: { 163 | use: 'add_to_boil', 164 | time: { value: 6, unit: 'min' }, 165 | }, 166 | form: 'pellet', 167 | beta_acid: { 168 | value: 3.5, 169 | unit: '%', 170 | }, 171 | }, 172 | ], 173 | 174 | culture_additions: [ 175 | { 176 | name: 'Edme Ale Yeast', 177 | type: 'ale', 178 | form: 'dry', 179 | amount: { value: 0.0250096, unit: 'l' }, 180 | amountIsWeight: false, 181 | laboratory: 'Edme', 182 | productId: '-', 183 | minTemperature: '16.6666667', 184 | maxTemperature: '22.2222222', 185 | flocculation: 'Medium', 186 | attenuation: { value: 75, unit: '%' }, 187 | notes: 188 | 'Quick starting dry yeast with a good reputation. Produces some fruity ester. Highly attentive, so it will likely produce a slightly dry taste.', 189 | bestFor: 'Ales requiring high attenuation.', 190 | maxReuse: '5', 191 | timesCultured: '0', 192 | addToSecondary: false, 193 | displayAmount: '25.01 ml', 194 | dispMinTemp: '62.0 F', 195 | dispMaxTemp: '72.0 F', 196 | inventory: '0.0 pkg', 197 | cultureDate: '14 Jun 2003', 198 | }, 199 | ], 200 | }, 201 | 202 | miscs: [ 203 | { 204 | name: 'Oats', 205 | version: '1', 206 | type: 'Other', 207 | use: 'Boil', 208 | amount: 0.453592, 209 | time: 0.0, 210 | amountIsWeight: true, 211 | useFor: 'mouth-feel', 212 | notes: 'pre-boil only', 213 | displayAmount: '1.00 lb', 214 | inventory: '0.00 lb', 215 | displayTime: '0.0 mins', 216 | batchSize: '5.00 gal', 217 | }, 218 | ], 219 | style: { 220 | name: 'Oatmeal Stout', 221 | version: '1', 222 | category: 'Stout', 223 | categoryNumber: '13', 224 | styleLetter: 'C', 225 | styleGuide: 'BJCP 2008', 226 | type: 'Ale', 227 | ogMin: '1.0480000', 228 | ogMax: '1.0650000', 229 | fgMin: '1.0100000', 230 | fgMax: '1.0180000', 231 | ibuMin: '25.0000000', 232 | ibuMax: '40.0000000', 233 | colorMin: '22.0000000', 234 | colorMax: '40.0000000', 235 | carbMin: '1.9000000', 236 | carbMax: '2.5000000', 237 | abvMax: '5.9000000', 238 | abvMin: '4.2000000', 239 | notes: 240 | 'A very dark, full-bodied, roasty, malty ale with a complementary oatmeal flavor. An English seasonal variant of sweet stout that is usually less sweet than the original, and relies on oatmeal for body and complexity rather than lactose for body and sweetness. Generally between sweet and dry stouts in sweetness. Variations exist, from fairly sweet to quite dry. The level of bitterness also varies, as does the oatmeal impression. Light use of oatmeal may give a certain silkiness of body and richness of flavor, while heavy use of oatmeal can be fairly intense in flavor with an almost oily mouthfeel. When judging, allow for differences in interpretation.', 241 | profile: 242 | 'Aroma: Mild roasted grain aromas, often with a coffee-like character. A light sweetness can imply a coffee-and-cream impression. Fruitiness should be low to medium. Diacetyl medium-low to none. Hop aroma low to none (UK varieties most common). A light oatmeal aroma is optional.\nAppearance: Medium brown to black in color. Thick, creamy, persistent tan- to brown-colored head. Can be opaque (if not, it should be clear).\nFlavor: Medium sweet to medium dry palate, with the complexity of oats and dark roasted grains present. Oats can add a nutty, grainy or earthy flavor. Dark grains can combine with malt sweetness to give the impression of milk chocolate or coffee with cream. Medium hop bitterness with the balance toward malt. Diacetyl medium-low to none. Hop flavor medium-low to none.\nMouthfeel: Medium-full to full body, smooth, silky, sometimes an almost oily slickness from the oatmeal. Creamy. Medium to medium-high carbonation.', 243 | ingredients: 244 | 'Pale, caramel and dark roasted malts and grains. Oatmeal (5-10%+) used to enhance fullness of body and complexity of flavor. Hops primarily for bittering. Ale yeast. Water source should have some carbonate hardness.', 245 | examples: 246 | 'Samuel Smith Oatmeal Stout, Young's Oatmeal Stout, McAuslan Oatmeal Stout, Maclay’s Oat Malt Stout, Broughton Kinmount Willie Oatmeal Stout, Anderson Valley Barney Flats Oatmeal Stout, Tröegs Oatmeal Stout, New Holland The Poet, Goose Island Oatmeal Stout, Wolaver’s Oatmeal Stout', 247 | displayOgMin: '1.048 SG', 248 | displayOgMax: '1.065 SG', 249 | displayFgMin: '1.010 SG', 250 | displayFgMax: '1.018 SG', 251 | displayColorMin: '22.0 SRM', 252 | displayColorMax: '40.0 SRM', 253 | ogRange: '1.048-1.065 SG', 254 | fgRange: '1.010-1.018 SG', 255 | ibuRange: '25.0-40.0 IBUs', 256 | carbRange: '1.90-2.50 Vols', 257 | colorRange: '22.0-40.0 SRM', 258 | abvRange: '4.20-5.90 %', 259 | }, 260 | mash: { 261 | name: 'My Mash', 262 | version: '1', 263 | grainTemp: 22.2222222, 264 | tunTemp: 22.2222222, 265 | spargeTemp: 75.5555556, 266 | ph: '5.4000000', 267 | tunWeight: '80.0000000', 268 | tunSpecificHeat: '0.1200000', 269 | equipAdjust: false, 270 | notes: '', 271 | displayGrainTemp: '72.0 F', 272 | displayTunTemp: '72.0 F', 273 | displaySpargeTemp: '168.0 F', 274 | displayTunWeight: '5 lbs', 275 | mashSteps: [], 276 | }, 277 | notes: 'Entered in Montgomery County Fair...finished middle of the pack.', 278 | tasteNotes: 279 | 'This was perhaps the best stout I've ever made. Unfortunately, it can't be made exactly again because Edme changed the yeast strain after I made this one. Good memories tho'.', 280 | tasteRating: '50.0000000', 281 | og: 1.063, 282 | fg: 1.015, 283 | carbonation: '2.4000000', 284 | fermentationStages: '2', 285 | primaryAge: '4.0000000', 286 | primaryTemp: '19.4444444', 287 | secondaryAge: '10.0000000', 288 | secondaryTemp: '19.4444444', 289 | tertiaryAge: '7.0000000', 290 | age: '30.0000000', 291 | ageTemp: '18.3333333', 292 | carbonationUsed: 'Bottle with 4.20 oz Corn Sugar', 293 | forcedCarbonation: true, 294 | primingSugarName: 'Corn Sugar', 295 | primingSugarEquiv: '1.0000000', 296 | kegPrimingFactor: '0.5000000', 297 | carbonationTemp: '21.1111111', 298 | displayCarbTemp: '70.0 F', 299 | date: '14 May 2011', 300 | estOg: '1.060 SG', 301 | estFg: '1.015 SG', 302 | estColor: '26.2 SRM', 303 | ibu: '31.7 IBUs', 304 | ibuMethod: 'Tinseth', 305 | estAbv: '5.9 %', 306 | abv: '6.3 %', 307 | actualEfficiency: '0.0 %', 308 | calories: '212.6 kcal/12oz', 309 | displayBatchSize: '5.00 gal', 310 | displayBoilSize: '3.91 gal', 311 | displayOg: '1.063 SG', 312 | displayFg: '1.015 SG', 313 | displayPrimaryTemp: '67.0 F', 314 | displaySecondaryTemp: '67.0 F', 315 | displayTertiaryTemp: '65.0 F', 316 | displayAgeTemp: '65.0 F', 317 | } 318 | -------------------------------------------------------------------------------- /tests/data/TestBeerJSON.json: -------------------------------------------------------------------------------- 1 | { 2 | "beerjson": { 3 | "version": 2.06, 4 | "recipes": [ 5 | { 6 | "name": "Test Recipe BeerJSON", 7 | "author": "Brewcalc", 8 | "type": "all grain", 9 | "style": { 10 | "name": "American Amber Ale", 11 | "category": "Amber And Brown American Beer", 12 | "category_number": 1, 13 | "category_id": "1", 14 | "style_id": "19A", 15 | "style_letter": "A", 16 | "style_guide": "BJCP2015", 17 | "type": "beer", 18 | "original_gravity": { 19 | "minimum": { 20 | "unit": "sg", 21 | "value": 1.045 22 | }, 23 | "maximum": { 24 | "unit": "sg", 25 | "value": 1.06 26 | } 27 | }, 28 | "final_gravity": { 29 | "minimum": { 30 | "unit": "sg", 31 | "value": 1.01 32 | }, 33 | "maximum": { 34 | "unit": "sg", 35 | "value": 1.015 36 | } 37 | }, 38 | "international_bitterness_units": { 39 | "minimum": 25, 40 | "maximum": 40 41 | }, 42 | "color": { 43 | "minimum": { 44 | "unit": "SRM", 45 | "value": 10 46 | }, 47 | "maximum": { 48 | "unit": "SRM", 49 | "value": 17 50 | } 51 | }, 52 | "alcohol_by_volume": { 53 | "minimum": 4.5, 54 | "maximum": 6.2 55 | } 56 | }, 57 | "batch_size": { 58 | "value": 20, 59 | "unit": "l" 60 | }, 61 | "boil": { 62 | "pre_boil_size": { 63 | "value": 12.5, 64 | "unit": "l" 65 | }, 66 | "boil_time": { 67 | "value": 60, 68 | "unit": "min" 69 | } 70 | }, 71 | "efficiency": { 72 | "brewhouse": { 73 | "value": 75, 74 | "unit": "%" 75 | } 76 | }, 77 | "ingredients": { 78 | "fermentable_additions": [ 79 | { 80 | "amount": { 81 | "value": 5, 82 | "unit": "kg" 83 | }, 84 | "name": "Default Malt", 85 | "type": "grain", 86 | "color": { 87 | "value": 10, 88 | "unit": "Lovi" 89 | }, 90 | "origin": "", 91 | "supplier": "", 92 | "grain_group": "base", 93 | "yield": { 94 | "potential": { 95 | "value": 1.036, 96 | "unit": "sg" 97 | } 98 | } 99 | } 100 | ], 101 | "hop_additions": [ 102 | { 103 | "amount": { 104 | "value": 10, 105 | "unit": "g" 106 | }, 107 | "name": "Default Hop", 108 | "alpha_acid": { 109 | "value": 5, 110 | "unit": "%" 111 | }, 112 | "beta_acid": { 113 | "value": 5, 114 | "unit": "%" 115 | }, 116 | "origin": "", 117 | "form": "leaf", 118 | "timing": { 119 | "use": "add_to_boil", 120 | "time": { 121 | "value": 60, 122 | "unit": "min" 123 | } 124 | } 125 | } 126 | ], 127 | "culture_additions": [ 128 | { 129 | "amount": { 130 | "value": 50, 131 | "unit": "ml" 132 | }, 133 | "name": "Default Culture", 134 | "attenuation": { 135 | "value": 75, 136 | "unit": "%" 137 | }, 138 | "type": "ale", 139 | "form": "dry" 140 | } 141 | ] 142 | }, 143 | "mash": { 144 | "name": "Single Infusion", 145 | "grain_temperature": { 146 | "unit": "C", 147 | "value": 22 148 | }, 149 | "sparge_temperature": { 150 | "unit": "C", 151 | "value": 75.6 152 | }, 153 | "mash_steps": [ 154 | { 155 | "name": "Mash In", 156 | "type": "infusion", 157 | "step_temperature": { 158 | "unit": "C", 159 | "value": 68 160 | }, 161 | "step_time": { 162 | "unit": "min", 163 | "value": 60 164 | }, 165 | "ramp_time": { 166 | "unit": "min", 167 | "value": 2 168 | }, 169 | "end_temperature": { 170 | "unit": "C", 171 | "value": 68 172 | } 173 | }, 174 | { 175 | "name": "Mash Out", 176 | "type": "infusion", 177 | "step_temperature": { 178 | "unit": "C", 179 | "value": 75.6 180 | }, 181 | "step_time": { 182 | "unit": "min", 183 | "value": 10 184 | }, 185 | "ramp_time": { 186 | "unit": "min", 187 | "value": 20 188 | }, 189 | "end_temperature": { 190 | "unit": "C", 191 | "value": 75.6 192 | } 193 | } 194 | ] 195 | }, 196 | "created": "2018-11-28T13:39:49.248Z" 197 | } 198 | ] 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /tests/data/TestEquipment.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'All Grain - Standard 5 Gal/19l Batch - Cooler', 3 | type: '', 4 | maximum_volume: '', 5 | loss: { 6 | value: 0.75, 7 | unit: 'gal', 8 | }, 9 | boil_rate_per_hour: { 10 | value: 0.54, 11 | unit: 'gal', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /tests/data/TestRecipeConverted.ts: -------------------------------------------------------------------------------- 1 | import { RecipeType } from '../../src/types/beerjson' 2 | 3 | const recipe: RecipeType = { 4 | name: 'BrewcalcTest', 5 | type: 'all grain', 6 | author: 'Brewcalc', 7 | created: '2018-12-25T21:00:00.000Z', 8 | batch_size: { 9 | unit: 'gal', 10 | value: 5, 11 | }, 12 | boil: { 13 | pre_boil_size: { 14 | unit: 'gal', 15 | value: 6.53, 16 | }, 17 | boil_time: { 18 | unit: 'min', 19 | value: 60, 20 | }, 21 | }, 22 | efficiency: { 23 | brewhouse: { 24 | unit: '%', 25 | value: 60, 26 | }, 27 | }, 28 | style: { 29 | name: 'Blonde Ale', 30 | category: 'Pale American Ale', 31 | style_guide: 'BJCP2015', 32 | type: 'beer', 33 | style_id: '18A', 34 | original_gravity: { 35 | minimum: { 36 | unit: 'sg', 37 | value: 1.038, 38 | }, 39 | maximum: { 40 | unit: 'sg', 41 | value: 1.054, 42 | }, 43 | }, 44 | final_gravity: { 45 | minimum: { 46 | unit: 'sg', 47 | value: 1.008, 48 | }, 49 | maximum: { 50 | unit: 'sg', 51 | value: 1.013, 52 | }, 53 | }, 54 | international_bitterness_units: { 55 | minimum: 15, 56 | maximum: 28, 57 | }, 58 | color: { 59 | minimum: { 60 | unit: 'SRM', 61 | value: 3, 62 | }, 63 | maximum: { 64 | unit: 'SRM', 65 | value: 6, 66 | }, 67 | }, 68 | alcohol_by_volume: { 69 | minimum: 3.8, 70 | maximum: 5.5, 71 | }, 72 | }, 73 | ingredients: { 74 | fermentable_additions: [ 75 | { 76 | name: 'Pale Malt, Maris Otter', 77 | type: 'grain', 78 | color: { 79 | unit: 'Lovi', 80 | value: 2.78, 81 | }, 82 | amount: { 83 | unit: 'lb', 84 | value: 9.3, 85 | }, 86 | origin: 'United Kingdom', 87 | yield: { 88 | potential: { 89 | unit: 'sg', 90 | value: 1.03795, 91 | }, 92 | }, 93 | }, 94 | { 95 | name: 'Caramel/Crystal Malt - 10L', 96 | type: 'grain', 97 | color: { 98 | unit: 'Lovi', 99 | value: 7.94, 100 | }, 101 | amount: { 102 | unit: 'lb', 103 | value: 0.5, 104 | }, 105 | origin: 'US', 106 | yield: { 107 | potential: { 108 | unit: 'sg', 109 | value: 1.0345, 110 | }, 111 | }, 112 | }, 113 | ], 114 | hop_additions: [ 115 | { 116 | name: 'Tradition', 117 | alpha_acid: { 118 | unit: '%', 119 | value: 6, 120 | }, 121 | origin: 'Germany', 122 | form: 'pellet', 123 | beta_acid: { 124 | unit: '%', 125 | value: 4.5, 126 | }, 127 | amount: { 128 | unit: 'oz', 129 | value: 1, 130 | }, 131 | timing: { 132 | time: { 133 | unit: 'min', 134 | value: 60, 135 | }, 136 | use: 'add_to_boil', 137 | }, 138 | }, 139 | { 140 | name: 'Citra', 141 | alpha_acid: { 142 | unit: '%', 143 | value: 12, 144 | }, 145 | origin: 'U.S.', 146 | form: 'leaf', 147 | beta_acid: { 148 | unit: '%', 149 | value: 4, 150 | }, 151 | amount: { 152 | unit: 'oz', 153 | value: 0.5, 154 | }, 155 | timing: { 156 | time: { 157 | unit: 'min', 158 | value: 2880, 159 | }, 160 | use: 'add_to_fermentation', 161 | }, 162 | }, 163 | ], 164 | culture_additions: [ 165 | { 166 | name: 'US West Coast', 167 | attenuation: { 168 | unit: '%', 169 | value: 81, 170 | }, 171 | type: 'ale', 172 | form: 'dry', 173 | amount: { 174 | unit: 'l', 175 | value: 0, 176 | }, 177 | }, 178 | ], 179 | }, 180 | mash: { 181 | name: 'Single Infusion, Full Body', 182 | grain_temperature: { 183 | unit: 'C', 184 | value: 22.2222222, 185 | }, 186 | notes: 187 | 'Simple single infusion mash for use with most modern well modified grains (about 95% of the time).', 188 | mash_steps: [ 189 | { 190 | name: 'Mash In', 191 | type: 'infusion', 192 | step_temperature: { 193 | unit: 'C', 194 | value: 68.8888889, 195 | }, 196 | step_time: { 197 | unit: 'min', 198 | value: 45, 199 | }, 200 | ramp_time: { 201 | unit: 'min', 202 | value: 2, 203 | }, 204 | end_temperature: { 205 | unit: 'C', 206 | value: 68.8888889, 207 | }, 208 | amount: { 209 | unit: 'l', 210 | value: 11.5928236, 211 | }, 212 | }, 213 | { 214 | name: 'Mash Out', 215 | type: 'infusion', 216 | step_temperature: { 217 | unit: 'C', 218 | value: 75.5555556, 219 | }, 220 | step_time: { 221 | unit: 'min', 222 | value: 10, 223 | }, 224 | ramp_time: { 225 | unit: 'min', 226 | value: 2, 227 | }, 228 | end_temperature: { 229 | unit: 'C', 230 | value: 75.5555556, 231 | }, 232 | amount: { 233 | unit: 'l', 234 | value: 4.6371294, 235 | }, 236 | }, 237 | ], 238 | }, 239 | } 240 | export default recipe 241 | -------------------------------------------------------------------------------- /tests/gravity.test.ts: -------------------------------------------------------------------------------- 1 | import { recipe as AussieAle } from './data/AussieAle' 2 | import { recipe as MuddyPig } from './data/MuddyPig' 3 | 4 | import { calcOriginalGravity, calcFinalGravity, calcBoilGravity } from '../src' 5 | 6 | describe('Gravity', () => { 7 | test('Calc original gravity', () => { 8 | const AussieAleOG = calcOriginalGravity( 9 | AussieAle.batch_size, 10 | AussieAle.ingredients.fermentable_additions, 11 | AussieAle.efficiency 12 | ) 13 | const MuddyPigOG = calcOriginalGravity( 14 | MuddyPig.batch_size, 15 | MuddyPig.ingredients.fermentable_additions, 16 | MuddyPig.efficiency 17 | ) 18 | 19 | expect(AussieAleOG.value).toBeCloseTo(1.044, 3) 20 | expect(MuddyPigOG.value).toBeCloseTo(1.063, 3) 21 | }) 22 | 23 | test('Calc final gravity', () => { 24 | const AussieAleFG = calcFinalGravity( 25 | AussieAle.batch_size, 26 | AussieAle.ingredients.fermentable_additions, 27 | AussieAle.efficiency, 28 | AussieAle.ingredients.culture_additions 29 | ) 30 | expect(AussieAleFG.value).toBeCloseTo(1.008, 2) 31 | 32 | const MuddyPigFG = calcFinalGravity( 33 | MuddyPig.batch_size, 34 | MuddyPig.ingredients.fermentable_additions, 35 | MuddyPig.efficiency, 36 | MuddyPig.ingredients.culture_additions 37 | ) 38 | expect(MuddyPigFG.value).toBeCloseTo(1.015, 2) 39 | }) 40 | 41 | test('Calc boil gravity', () => { 42 | const AussieAleOG = calcOriginalGravity( 43 | AussieAle.batch_size, 44 | AussieAle.ingredients.fermentable_additions, 45 | AussieAle.efficiency 46 | ) 47 | const AussieAleBG = calcBoilGravity( 48 | AussieAle.batch_size, 49 | AussieAle.boil.pre_boil_size, 50 | AussieAleOG 51 | ) 52 | expect(AussieAleBG.value).toBeCloseTo(1.031, 2) 53 | 54 | const MuddyPigOG = calcOriginalGravity( 55 | MuddyPig.batch_size, 56 | MuddyPig.ingredients.fermentable_additions, 57 | MuddyPig.efficiency 58 | ) 59 | const MuddyPigBG = calcBoilGravity( 60 | MuddyPig.batch_size, 61 | MuddyPig.boil.pre_boil_size, 62 | MuddyPigOG 63 | ) 64 | expect(MuddyPigBG.value).toBeCloseTo(1.084, 2) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /tests/mash.test.ts: -------------------------------------------------------------------------------- 1 | import { recipe } from './data/AussieAle' 2 | import { 3 | calcMashGrainWeight, 4 | convertMeasurableValue, 5 | recalculateMashSteps, 6 | } from '../src' 7 | 8 | describe('Mash steps calculation', () => { 9 | test('Calc mash grain weight', () => { 10 | const mashGrainWeight = calcMashGrainWeight( 11 | recipe.ingredients.fermentable_additions 12 | ) 13 | expect(convertMeasurableValue(mashGrainWeight, 'kg')).toBeCloseTo(5.26, 2) 14 | expect(mashGrainWeight.unit).toEqual('lb') 15 | }) 16 | 17 | test.skip('Recalc Mash Steps', () => { 18 | const mashGrainWeight = calcMashGrainWeight( 19 | recipe.ingredients.fermentable_additions 20 | ) 21 | const mashSteps = recalculateMashSteps( 22 | recipe.mash.mash_steps, 23 | recipe.mash.grain_temperature, 24 | mashGrainWeight 25 | ) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/volumes.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calcBoilVolumes, 3 | calcMashVolumes, 4 | calcMashGrainWeight, 5 | convertMeasurableValue, 6 | } from '../src' 7 | import { recipe as AussieAle } from './data/AussieAle' 8 | import { equipment as AussieAleEquipment } from './data/AussieAle' 9 | 10 | describe('Volumes', () => { 11 | const equipment = { 12 | mash_tun: AussieAleEquipment.equipment_items[0], 13 | brew_kettle: AussieAleEquipment.equipment_items[1], 14 | } 15 | 16 | test('Calc pre boil volume', () => { 17 | const { pre_boil_size } = calcBoilVolumes( 18 | AussieAle.batch_size, 19 | AussieAle.boil, 20 | equipment 21 | ) 22 | expect(convertMeasurableValue(pre_boil_size, 'l')).toBeCloseTo(37.12, 1) 23 | }) 24 | 25 | test('Calc mash water volume, sparge volume, total volume', () => { 26 | const mashGrainWeight = calcMashGrainWeight( 27 | AussieAle.ingredients.fermentable_additions 28 | ) 29 | const { pre_boil_size } = calcBoilVolumes( 30 | AussieAle.batch_size, 31 | AussieAle.boil, 32 | equipment 33 | ) 34 | const { mash_volume, sparge_volume, total_volume } = calcMashVolumes( 35 | pre_boil_size, 36 | AussieAle.mash.mash_steps, 37 | mashGrainWeight, 38 | equipment 39 | ) 40 | 41 | expect(convertMeasurableValue(mash_volume, 'l')).toBeCloseTo(24.45, 1) 42 | 43 | expect(convertMeasurableValue(sparge_volume, 'l')).toBeCloseTo(20.97, 1) 44 | 45 | expect(convertMeasurableValue(total_volume, 'l')).toBeCloseTo(45.42, 1) 46 | }) 47 | 48 | /*test('mashGrainWeight', () => { 49 | expect( 50 | calculateVolumes(AussieAle, AussieAleEquipment).mashGrainWeight 51 | ).toBeCloseTo(5.26, 2) 52 | }) 53 | test('grainAbsorbtion', () => { 54 | expect( 55 | calculateVolumes(AussieAle, AussieAleEquipment).grainAbsorbtion 56 | ).toBeCloseTo(5.27, 2) 57 | }) 58 | 59 | test('totalMashWaterAdds', () => { 60 | expect( 61 | calculateVolumes(AussieAle, AussieAleEquipment).totalMashWaterAdds 62 | ).toBeCloseTo(24.45, 2) 63 | }) 64 | 65 | test('mashVolumeNeeded', () => { 66 | expect( 67 | calculateVolumes(AussieAle, AussieAleEquipment).mashVolumeNeeded 68 | ).toBeCloseTo(27.97, 2) 69 | }) 70 | 71 | test('waterAvailFromMash', () => { 72 | expect( 73 | calculateVolumes(AussieAle, AussieAleEquipment).waterAvailFromMash 74 | ).toBeCloseTo(19.17, 2) 75 | }) 76 | 77 | test('spargeVol', () => { 78 | expect( 79 | calculateVolumes(AussieAle, AussieAleEquipment).spargeVol 80 | ).toBeCloseTo(20.97, 1) 81 | }) 82 | 83 | test('estPreBoilVolume', () => { 84 | expect( 85 | calculateVolumes(AussieAle, AussieAleEquipment).estPreBoilVolume 86 | ).toBeCloseTo(37.12, 2) 87 | }) 88 | 89 | test('boilOffVolume', () => { 90 | expect( 91 | calculateVolumes(AussieAle, AussieAleEquipment).boilOffVolume 92 | ).toBeCloseTo(10.18, 2) 93 | }) 94 | 95 | test('postBoilVolume', () => { 96 | expect( 97 | calculateVolumes(AussieAle, AussieAleEquipment).postBoilVolume 98 | ).toBeCloseTo(26.94, 2) 99 | }) 100 | 101 | test('coolingShrinkage', () => { 102 | expect( 103 | calculateVolumes(AussieAle, AussieAleEquipment).coolingShrinkage 104 | ).toBeCloseTo(1.08, 2) 105 | }) 106 | 107 | test('estBottlingVol', () => { 108 | expect( 109 | calculateVolumes(AussieAle, AussieAleEquipment).estBottlingVol 110 | ).toBeCloseTo(21.32, 2) 111 | }) 112 | 113 | test('totalWater', () => { 114 | expect( 115 | calculateVolumes(AussieAle, AussieAleEquipment).totalWater 116 | ).toBeCloseTo(45.42, 2) 117 | })*/ 118 | }) 119 | -------------------------------------------------------------------------------- /tests/waterChem.test.ts: -------------------------------------------------------------------------------- 1 | import { calcWaterChemistry } from '../src/waterChem' 2 | import type { Water } from '../src/types/water' 3 | import type { SaltAdditions } from '../src/types/saltAdditions' 4 | import { VolumeType } from '../src/types/beerjson' 5 | 6 | test('calcWaterChemistry', () => { 7 | const batchSize: VolumeType = { value: 20, unit: 'l' } 8 | const dilution = 0.5 9 | const sourceWater: Water = { 10 | name: 'Pilsen (Light Lager)', 11 | Ca: 7, 12 | Mg: 3, 13 | SO4: 5, 14 | Na: 2, 15 | Cl: 5, 16 | HCO3: 25, 17 | } 18 | 19 | const targetWater: Water = { 20 | name: 'Dublin (Dry Stout)', 21 | Ca: 110, 22 | Mg: 4, 23 | SO4: 53, 24 | Na: 12, 25 | Cl: 19, 26 | HCO3: 280, 27 | } 28 | 29 | const saltAdditions: SaltAdditions = { 30 | CaCO3: 10, 31 | NaHCO3: 2, 32 | CaSO4: 2, 33 | CaCl2: 1, 34 | MgSO4: 1, 35 | NaCl: 1, 36 | } 37 | 38 | const adjustedWater: Water = { 39 | name: 'adjustedWater', 40 | Ca: 140, 41 | Mg: 7, 42 | SO4: 78, 43 | Na: 49, 44 | Cl: 57, 45 | HCO3: 389, 46 | alkalinity: 319, 47 | } 48 | const dilutedWater: Water = { 49 | name: 'dilutedWater', 50 | Ca: 4, 51 | Mg: 2, 52 | SO4: 3, 53 | Na: 1, 54 | Cl: 3, 55 | HCO3: 13, 56 | alkalinity: 10, 57 | } 58 | const adjustmentsFromSalts: Water = { 59 | name: 'adjustmentsFromSalts', 60 | Ca: 136, 61 | Mg: 5, 62 | SO4: 75, 63 | Na: 48, 64 | Cl: 54, 65 | HCO3: 376, 66 | alkalinity: 308, 67 | } 68 | const difference: Water = { 69 | name: 'difference source water from target', 70 | Ca: 30, 71 | Mg: 3, 72 | SO4: 25, 73 | Na: 37, 74 | Cl: 38, 75 | HCO3: 109, 76 | alkalinity: 89, 77 | } 78 | const sulphateChlorideRatio = 1.368 79 | 80 | expect( 81 | calcWaterChemistry( 82 | batchSize, 83 | dilution, 84 | sourceWater, 85 | targetWater, 86 | saltAdditions 87 | ).dilutedWater 88 | ).toMatchObject(dilutedWater) 89 | 90 | expect( 91 | calcWaterChemistry( 92 | batchSize, 93 | dilution, 94 | sourceWater, 95 | targetWater, 96 | saltAdditions 97 | ).adjustmentsFromSalts 98 | ).toMatchObject(adjustmentsFromSalts) 99 | 100 | expect( 101 | calcWaterChemistry( 102 | batchSize, 103 | dilution, 104 | sourceWater, 105 | targetWater, 106 | saltAdditions 107 | ).adjustedWater 108 | ).toMatchObject(adjustedWater) 109 | 110 | expect( 111 | calcWaterChemistry( 112 | batchSize, 113 | dilution, 114 | sourceWater, 115 | targetWater, 116 | saltAdditions 117 | ).difference 118 | ).toMatchObject(difference) 119 | 120 | expect( 121 | calcWaterChemistry( 122 | batchSize, 123 | dilution, 124 | sourceWater, 125 | targetWater, 126 | saltAdditions 127 | ).sulphateChlorideRatio 128 | ).toBeCloseTo(sulphateChlorideRatio) 129 | }) 130 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015"], 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "resolveJsonModule": true, 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "target": "es2015", 10 | "sourceMap": true, 11 | "outDir": "lib" 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const commonConfig = { 2 | entry: './src/index.ts', 3 | devtool: 'inline-source-map', 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.ts$/, 8 | use: 'ts-loader', 9 | exclude: /node_modules/, 10 | }, 11 | ], 12 | }, 13 | resolve: { 14 | extensions: ['.ts', '.js'], 15 | }, 16 | target: 'web', 17 | } 18 | 19 | const prodConfig = { 20 | mode: 'production', 21 | output: { 22 | library: 'brewcalc', 23 | libraryTarget: 'umd', 24 | path: __dirname + '/web', 25 | filename: 'brewcalc.min.js', 26 | }, 27 | ...commonConfig, 28 | } 29 | 30 | const devConfig = { 31 | mode: 'development', 32 | output: { 33 | library: 'brewcalc', 34 | libraryTarget: 'umd', 35 | path: __dirname + '/web', 36 | filename: 'brewcalc.js', 37 | }, 38 | ...commonConfig, 39 | } 40 | 41 | module.exports = [prodConfig, devConfig] 42 | --------------------------------------------------------------------------------