├── .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 
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 |
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 | Amount |
16 | Name |
17 | Type |
18 |
19 |
20 |
21 | {fermentable_additions.map((i, index) => (
22 |
23 |
24 |
25 | |
26 |
27 | {i.name} ({printMeasurable(i.color, "SRM", 0)})
28 | |
29 | {i.type} |
30 |
31 | ))}
32 | {hop_additions.map((i, index) => (
33 |
34 |
35 |
36 | |
37 |
38 | {i.name} ({printMeasurable(i.alpha_acid)} Alpha, Boil time{" "}
39 | {printMeasurable(i.timing.time)})
40 | |
41 | Hop |
42 |
43 | ))}
44 | {culture_additions.map((i, index) => (
45 |
46 | {printMeasurable(i.amount, null, 2)} |
47 |
48 | {i.name} (Attenuation {printMeasurable(i.attenuation, null, 2)},
49 | Form {i.form})
50 | |
51 | Yeast |
52 |
53 | ))}
54 |
55 |
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 | Name |
108 | Description |
109 | Step Temperature |
110 | Step Time |
111 |
112 |
113 |
114 | {recalculatedMashSteps.map((step, index) => (
115 |
116 | {step.name} |
117 |
118 |
119 |
124 | |
125 |
126 |
131 | |
132 |
133 | ))}
134 |
135 |
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 |
169 | Amount
170 | |
171 |
172 | Name
173 | |
174 |
175 | Type
176 | |
177 |
178 |
179 |
180 |
181 |
182 |
183 | 4.5 kg
184 | (9.91 lb)
185 |
186 | |
187 |
188 | Pale Malt (2 Row) UK
189 | (
190 | 3 SRM
191 | )
192 | |
193 |
200 | grain
201 | |
202 |
203 |
204 |
205 |
206 | 0.52 kg
207 | (1.14 lb)
208 |
209 | |
210 |
211 | Munich Malt - 20L
212 | (
213 | 20 SRM
214 | )
215 | |
216 |
223 | grain
224 | |
225 |
226 |
227 |
228 |
229 | 0.2 kg
230 | (0.44 lb)
231 |
232 | |
233 |
234 | Caramel/Crystal Malt - 20L
235 | (
236 | 20 SRM
237 | )
238 | |
239 |
246 | grain
247 | |
248 |
249 |
250 |
251 |
252 | 0.05 kg
253 | (0.1 lb)
254 |
255 | |
256 |
257 | Roasted Barley
258 | (
259 | 300 SRM
260 | )
261 | |
262 |
269 | grain
270 | |
271 |
272 |
273 |
274 |
275 | 0.26 kg
276 | (0.58 lb)
277 |
278 | |
279 |
280 | Cane (Beet) Sugar
281 | (
282 | 0 SRM
283 | )
284 | |
285 |
292 | sugar
293 | |
294 |
295 |
296 |
297 |
298 | 5.21 g
299 | (0.18 oz)
300 |
301 | |
302 |
303 | Pride of Ringwood
304 | (
305 | 10 %
306 | Alpha, Boil time
307 |
308 | 60 min
309 | )
310 | |
311 |
312 | Hop
313 | |
314 |
315 |
316 |
317 |
318 | 5.21 g
319 | (0.18 oz)
320 |
321 | |
322 |
323 | Pride of Ringwood
324 | (
325 | 10 %
326 | Alpha, Boil time
327 |
328 | 45 min
329 | )
330 | |
331 |
332 | Hop
333 | |
334 |
335 |
336 |
337 |
338 | 31.23 g
339 | (1.1 oz)
340 |
341 | |
342 |
343 | Pride of Ringwood
344 | (
345 | 10 %
346 | Alpha, Boil time
347 |
348 | 15 min
349 | )
350 | |
351 |
352 | Hop
353 | |
354 |
355 |
356 |
357 |
358 | 15.68 g
359 | (0.55 oz)
360 |
361 | |
362 |
363 | Pride of Ringwood
364 | (
365 | 10 %
366 | Alpha, Boil time
367 |
368 | 0 min
369 | )
370 | |
371 |
372 | Hop
373 | |
374 |
375 |
376 |
377 | 0.13 ml
378 | |
379 |
380 | American Ale
381 | (Attenuation
382 | 75 %
383 | , Form
384 | liquid
385 | )
386 | |
387 |
388 | Yeast
389 | |
390 |
391 |
392 |
393 |
394 |
397 |
400 | Mash Steps
401 |
402 |
405 |
406 |
407 |
408 | Name
409 | |
410 |
411 | Description
412 | |
413 |
414 | Step Temperature
415 | |
416 |
417 | Step Time
418 | |
419 |
420 |
421 |
422 |
423 |
424 | Mash In
425 | |
426 |
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 | |
444 |
445 |
446 | 67 °C
447 | (152 °F)
448 |
449 | |
450 |
451 |
452 | 60 min
453 |
454 | |
455 |
456 |
457 |
458 | Mash Out
459 | |
460 |
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 | |
478 |
479 |
480 | 76 °C
481 | (168 °F)
482 |
483 | |
484 |
485 |
486 | 10 min
487 |
488 | |
489 |
490 |
491 |
492 | Sparge
493 | |
494 |
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 | |
512 |
513 |
514 | 76 °C
515 | (168 °F)
516 |
517 | |
518 |
519 |
520 | 10 min
521 |
522 | |
523 |
524 |
525 |
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 |
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 |
738 | Amount
739 | |
740 |
741 | Name
742 | |
743 |
744 | Type
745 | |
746 |
747 |
748 |
749 |
750 |
751 |
752 | 4.5 kg
753 | (9.91 lb)
754 |
755 | |
756 |
757 | Pale Malt (2 Row) UK
758 | (
759 | 3 SRM
760 | )
761 | |
762 |
769 | grain
770 | |
771 |
772 |
773 |
774 |
775 | 0.52 kg
776 | (1.14 lb)
777 |
778 | |
779 |
780 | Munich Malt - 20L
781 | (
782 | 20 SRM
783 | )
784 | |
785 |
792 | grain
793 | |
794 |
795 |
796 |
797 |
798 | 0.2 kg
799 | (0.44 lb)
800 |
801 | |
802 |
803 | Caramel/Crystal Malt - 20L
804 | (
805 | 20 SRM
806 | )
807 | |
808 |
815 | grain
816 | |
817 |
818 |
819 |
820 |
821 | 0.05 kg
822 | (0.1 lb)
823 |
824 | |
825 |
826 | Roasted Barley
827 | (
828 | 300 SRM
829 | )
830 | |
831 |
838 | grain
839 | |
840 |
841 |
842 |
843 |
844 | 0.26 kg
845 | (0.58 lb)
846 |
847 | |
848 |
849 | Cane (Beet) Sugar
850 | (
851 | 0 SRM
852 | )
853 | |
854 |
861 | sugar
862 | |
863 |
864 |
865 |
866 |
867 | 5.21 g
868 | (0.18 oz)
869 |
870 | |
871 |
872 | Pride of Ringwood
873 | (
874 | 10 %
875 | Alpha, Boil time
876 |
877 | 60 min
878 | )
879 | |
880 |
881 | Hop
882 | |
883 |
884 |
885 |
886 |
887 | 5.21 g
888 | (0.18 oz)
889 |
890 | |
891 |
892 | Pride of Ringwood
893 | (
894 | 10 %
895 | Alpha, Boil time
896 |
897 | 45 min
898 | )
899 | |
900 |
901 | Hop
902 | |
903 |
904 |
905 |
906 |
907 | 31.23 g
908 | (1.1 oz)
909 |
910 | |
911 |
912 | Pride of Ringwood
913 | (
914 | 10 %
915 | Alpha, Boil time
916 |
917 | 15 min
918 | )
919 | |
920 |
921 | Hop
922 | |
923 |
924 |
925 |
926 |
927 | 15.68 g
928 | (0.55 oz)
929 |
930 | |
931 |
932 | Pride of Ringwood
933 | (
934 | 10 %
935 | Alpha, Boil time
936 |
937 | 0 min
938 | )
939 | |
940 |
941 | Hop
942 | |
943 |
944 |
945 |
946 | 0.13 ml
947 | |
948 |
949 | American Ale
950 | (Attenuation
951 | 75 %
952 | , Form
953 | liquid
954 | )
955 | |
956 |
957 | Yeast
958 | |
959 |
960 |
961 |
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 |
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 |
--------------------------------------------------------------------------------