IBU (International Bitterness Units) Calculator

Professional IBU calculator for homebrewers and craft brewers. Compute bitterness using the Tinseth method with multiple hop additions, pellet/whole adjustments, and whirlpool estimates.

IBU (International Bitterness Units) Calculator

This professional-grade IBU calculator helps homebrewers and craft brewers estimate beer bitterness with precision. It supports multiple hop additions, pellet vs. whole adjustments, and whirlpool/steep estimates, all grounded in the Tinseth method for transparent, repeatable results.

Authoritative Content Ecosystem

Data Source and Methodology

Authoritative Sources

  • Tinseth (1997) — The Relative Utilization of Alpha Acids From Hops (utilization model widely adopted by brewers). Reference: Glenn Tinseth. Link: https://www.tinseth.com/
  • Malowicki (2005) — A Study of Factors Affecting the Formation of Iso-Alpha-Acids During Wort Boiling (temperature/time kinetics for isomerization). DOI: 10.1094/ASBCJ-63-007. Abstract: ASBC Journal

All calculations are strictly based on the formulas and data provided by this source.

Specifically, boil additions use the Tinseth utilization equation. Whirlpool/steep estimates adapt Tinseth with a temperature factor informed by Malowicki’s kinetics. Pellet hops apply a commonly accepted 10% utilization increase versus whole/leaf hops.

The Formulas Explained

Tinseth utilization (boil):

U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}

IBU contribution (per addition):

\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}

Pellet vs. whole adjustment:

U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}

Whirlpool/steep temperature factor:

U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08

Glossary of Variables

  • G: Specific gravity of the wort during boil (e.g., 1.050).
  • t: Time in minutes the hops are exposed at the specified conditions (boil or whirlpool).
  • U: Tinseth utilization (dimensionless), fraction of alpha acids isomerized and retained.
  • w_g: Hop weight in grams for a single addition.
  • α: Alpha acid fraction (e.g., 12% → 0.12).
  • V_l: Final batch volume in liters.
  • f_form: 1.10 for pellet hops; 1.00 for whole/leaf hops.
  • f_T(T): Whirlpool temperature factor (dimensionless) scaling utilization below boiling.
  • IBU: International Bitterness Units, mg of iso-alpha acids per liter of beer.

Worked Example

How It Works: A Step-by-Step Example

Goal: 20 L batch, wort gravity G = 1.050.

  • Hop A (pellet): 20 g, α = 12%, boil 60 min.
  • Hop B (pellet): 15 g, α = 8%, whirlpool 20 min at 80°C.

1) Tinseth utilization for Hop A:

U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246

Pellet adjustment: U_{\text{adj}} = 0.246 \times 1.10 \approx 0.271

IBU(A): \dfrac{20 \times 1000 \times 0.12 \times 0.271}{20} \approx 32.5

2) Whirlpool factor at 80°C: f_T(80^{\circ}\mathrm{C}) \approx 0.32

Base U at 20 min: U \approx 0.105 → Pellet-adjusted: 0.105 \times 1.10 = 0.116 → Whirlpool-adjusted: U_{\text{whirlpool}} \approx 0.116 \times 0.32 = 0.037

IBU(B): \dfrac{15 \times 1000 \times 0.08 \times 0.037}{20} \approx 2.2

Total IBU ≈ 34.7

Frequently Asked Questions (FAQ)

Which utilization model do you use?

Boil IBUs are calculated using the Tinseth method. Whirlpool/steep IBUs apply a temperature factor guided by Malowicki’s kinetics.

How do pellet vs. whole hops affect IBUs?

Pellet hops are modeled with a 10% higher utilization than whole/leaf hops due to better surface area and extraction.

Do I use pre-boil or post-boil gravity?

Use the gravity representative of the boil—most homebrew calculators default to post-boil OG, which is a practical approximation.

Can I include dry hop additions?

Dry hops provide aroma and some polyphenol bitterness but do not significantly raise iso-alpha acids; they are typically excluded from IBU calculations.

Why don’t different calculators match exactly?

Differences in utilization curves, temperature assumptions for whirlpool, volume definitions, and rounding can produce small variations.

What about boil-off or trub losses?

Tinseth’s core formula uses final volume. Losses indirectly affect calculated IBUs by changing the final concentration; enter the fermenter volume for best consistency.

Is there a max IBU?

In practice, solubility and sensory thresholds limit measurable IBUs. Extremely high hopping rates may not translate linearly into higher IBU readings.

Authorship and Review


Audit: Complete
Formula (LaTeX) + variables + units
This section shows the formulas used by the calculator engine, plus variable definitions and units.
Formula (extracted LaTeX)
\[','\]
','
Formula (extracted LaTeX)
\[= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle\]
= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle
Formula (extracted LaTeX)
\[('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];\]
('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];
Formula (extracted text)
Tinseth utilization (boil): U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15} IBU contribution (per addition): \mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l} Pellet vs. whole adjustment: U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases} Whirlpool/steep temperature factor: U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}
Formula (extracted text)
\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}
Formula (extracted text)
U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}
Formula (extracted text)
U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246
Variables and units
  • No variables provided in audit spec.
Sources (authoritative):
Changelog
Version: 0.1.0-draft
Last code update: 2026-01-19
0.1.0-draft · 2026-01-19
  • Initial audit spec draft generated from HTML extraction (review required).
  • Verify formulas match the calculator engine and convert any text-only formulas to LaTeX.
  • Confirm sources are authoritative and relevant to the calculator methodology.
Verified by Ugo Candido on 2026-01-19
Profile · LinkedIn

Full original guide (expanded)

IBU (International Bitterness Units) Calculator

This professional-grade IBU calculator helps homebrewers and craft brewers estimate beer bitterness with precision. It supports multiple hop additions, pellet vs. whole adjustments, and whirlpool/steep estimates, all grounded in the Tinseth method for transparent, repeatable results.

Authoritative Content Ecosystem

Data Source and Methodology

Authoritative Sources

  • Tinseth (1997) — The Relative Utilization of Alpha Acids From Hops (utilization model widely adopted by brewers). Reference: Glenn Tinseth. Link: https://www.tinseth.com/
  • Malowicki (2005) — A Study of Factors Affecting the Formation of Iso-Alpha-Acids During Wort Boiling (temperature/time kinetics for isomerization). DOI: 10.1094/ASBCJ-63-007. Abstract: ASBC Journal

All calculations are strictly based on the formulas and data provided by this source.

Specifically, boil additions use the Tinseth utilization equation. Whirlpool/steep estimates adapt Tinseth with a temperature factor informed by Malowicki’s kinetics. Pellet hops apply a commonly accepted 10% utilization increase versus whole/leaf hops.

The Formulas Explained

Tinseth utilization (boil):

U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}

IBU contribution (per addition):

\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}

Pellet vs. whole adjustment:

U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}

Whirlpool/steep temperature factor:

U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08

Glossary of Variables

  • G: Specific gravity of the wort during boil (e.g., 1.050).
  • t: Time in minutes the hops are exposed at the specified conditions (boil or whirlpool).
  • U: Tinseth utilization (dimensionless), fraction of alpha acids isomerized and retained.
  • w_g: Hop weight in grams for a single addition.
  • α: Alpha acid fraction (e.g., 12% → 0.12).
  • V_l: Final batch volume in liters.
  • f_form: 1.10 for pellet hops; 1.00 for whole/leaf hops.
  • f_T(T): Whirlpool temperature factor (dimensionless) scaling utilization below boiling.
  • IBU: International Bitterness Units, mg of iso-alpha acids per liter of beer.

Worked Example

How It Works: A Step-by-Step Example

Goal: 20 L batch, wort gravity G = 1.050.

  • Hop A (pellet): 20 g, α = 12%, boil 60 min.
  • Hop B (pellet): 15 g, α = 8%, whirlpool 20 min at 80°C.

1) Tinseth utilization for Hop A:

U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246

Pellet adjustment: U_{\text{adj}} = 0.246 \times 1.10 \approx 0.271

IBU(A): \dfrac{20 \times 1000 \times 0.12 \times 0.271}{20} \approx 32.5

2) Whirlpool factor at 80°C: f_T(80^{\circ}\mathrm{C}) \approx 0.32

Base U at 20 min: U \approx 0.105 → Pellet-adjusted: 0.105 \times 1.10 = 0.116 → Whirlpool-adjusted: U_{\text{whirlpool}} \approx 0.116 \times 0.32 = 0.037

IBU(B): \dfrac{15 \times 1000 \times 0.08 \times 0.037}{20} \approx 2.2

Total IBU ≈ 34.7

Frequently Asked Questions (FAQ)

Which utilization model do you use?

Boil IBUs are calculated using the Tinseth method. Whirlpool/steep IBUs apply a temperature factor guided by Malowicki’s kinetics.

How do pellet vs. whole hops affect IBUs?

Pellet hops are modeled with a 10% higher utilization than whole/leaf hops due to better surface area and extraction.

Do I use pre-boil or post-boil gravity?

Use the gravity representative of the boil—most homebrew calculators default to post-boil OG, which is a practical approximation.

Can I include dry hop additions?

Dry hops provide aroma and some polyphenol bitterness but do not significantly raise iso-alpha acids; they are typically excluded from IBU calculations.

Why don’t different calculators match exactly?

Differences in utilization curves, temperature assumptions for whirlpool, volume definitions, and rounding can produce small variations.

What about boil-off or trub losses?

Tinseth’s core formula uses final volume. Losses indirectly affect calculated IBUs by changing the final concentration; enter the fermenter volume for best consistency.

Is there a max IBU?

In practice, solubility and sensory thresholds limit measurable IBUs. Extremely high hopping rates may not translate linearly into higher IBU readings.

Authorship and Review


Audit: Complete
Formula (LaTeX) + variables + units
This section shows the formulas used by the calculator engine, plus variable definitions and units.
Formula (extracted LaTeX)
\[','\]
','
Formula (extracted LaTeX)
\[= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle\]
= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle
Formula (extracted LaTeX)
\[('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];\]
('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];
Formula (extracted text)
Tinseth utilization (boil): U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15} IBU contribution (per addition): \mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l} Pellet vs. whole adjustment: U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases} Whirlpool/steep temperature factor: U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}
Formula (extracted text)
\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}
Formula (extracted text)
U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}
Formula (extracted text)
U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246
Variables and units
  • No variables provided in audit spec.
Sources (authoritative):
Changelog
Version: 0.1.0-draft
Last code update: 2026-01-19
0.1.0-draft · 2026-01-19
  • Initial audit spec draft generated from HTML extraction (review required).
  • Verify formulas match the calculator engine and convert any text-only formulas to LaTeX.
  • Confirm sources are authoritative and relevant to the calculator methodology.
Verified by Ugo Candido on 2026-01-19
Profile · LinkedIn

IBU (International Bitterness Units) Calculator

This professional-grade IBU calculator helps homebrewers and craft brewers estimate beer bitterness with precision. It supports multiple hop additions, pellet vs. whole adjustments, and whirlpool/steep estimates, all grounded in the Tinseth method for transparent, repeatable results.

Authoritative Content Ecosystem

Data Source and Methodology

Authoritative Sources

  • Tinseth (1997) — The Relative Utilization of Alpha Acids From Hops (utilization model widely adopted by brewers). Reference: Glenn Tinseth. Link: https://www.tinseth.com/
  • Malowicki (2005) — A Study of Factors Affecting the Formation of Iso-Alpha-Acids During Wort Boiling (temperature/time kinetics for isomerization). DOI: 10.1094/ASBCJ-63-007. Abstract: ASBC Journal

All calculations are strictly based on the formulas and data provided by this source.

Specifically, boil additions use the Tinseth utilization equation. Whirlpool/steep estimates adapt Tinseth with a temperature factor informed by Malowicki’s kinetics. Pellet hops apply a commonly accepted 10% utilization increase versus whole/leaf hops.

The Formulas Explained

Tinseth utilization (boil):

U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}

IBU contribution (per addition):

\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}

Pellet vs. whole adjustment:

U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}

Whirlpool/steep temperature factor:

U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08

Glossary of Variables

  • G: Specific gravity of the wort during boil (e.g., 1.050).
  • t: Time in minutes the hops are exposed at the specified conditions (boil or whirlpool).
  • U: Tinseth utilization (dimensionless), fraction of alpha acids isomerized and retained.
  • w_g: Hop weight in grams for a single addition.
  • α: Alpha acid fraction (e.g., 12% → 0.12).
  • V_l: Final batch volume in liters.
  • f_form: 1.10 for pellet hops; 1.00 for whole/leaf hops.
  • f_T(T): Whirlpool temperature factor (dimensionless) scaling utilization below boiling.
  • IBU: International Bitterness Units, mg of iso-alpha acids per liter of beer.

Worked Example

How It Works: A Step-by-Step Example

Goal: 20 L batch, wort gravity G = 1.050.

  • Hop A (pellet): 20 g, α = 12%, boil 60 min.
  • Hop B (pellet): 15 g, α = 8%, whirlpool 20 min at 80°C.

1) Tinseth utilization for Hop A:

U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246

Pellet adjustment: U_{\text{adj}} = 0.246 \times 1.10 \approx 0.271

IBU(A): \dfrac{20 \times 1000 \times 0.12 \times 0.271}{20} \approx 32.5

2) Whirlpool factor at 80°C: f_T(80^{\circ}\mathrm{C}) \approx 0.32

Base U at 20 min: U \approx 0.105 → Pellet-adjusted: 0.105 \times 1.10 = 0.116 → Whirlpool-adjusted: U_{\text{whirlpool}} \approx 0.116 \times 0.32 = 0.037

IBU(B): \dfrac{15 \times 1000 \times 0.08 \times 0.037}{20} \approx 2.2

Total IBU ≈ 34.7

Frequently Asked Questions (FAQ)

Which utilization model do you use?

Boil IBUs are calculated using the Tinseth method. Whirlpool/steep IBUs apply a temperature factor guided by Malowicki’s kinetics.

How do pellet vs. whole hops affect IBUs?

Pellet hops are modeled with a 10% higher utilization than whole/leaf hops due to better surface area and extraction.

Do I use pre-boil or post-boil gravity?

Use the gravity representative of the boil—most homebrew calculators default to post-boil OG, which is a practical approximation.

Can I include dry hop additions?

Dry hops provide aroma and some polyphenol bitterness but do not significantly raise iso-alpha acids; they are typically excluded from IBU calculations.

Why don’t different calculators match exactly?

Differences in utilization curves, temperature assumptions for whirlpool, volume definitions, and rounding can produce small variations.

What about boil-off or trub losses?

Tinseth’s core formula uses final volume. Losses indirectly affect calculated IBUs by changing the final concentration; enter the fermenter volume for best consistency.

Is there a max IBU?

In practice, solubility and sensory thresholds limit measurable IBUs. Extremely high hopping rates may not translate linearly into higher IBU readings.

Authorship and Review


Audit: Complete
Formula (LaTeX) + variables + units
This section shows the formulas used by the calculator engine, plus variable definitions and units.
Formula (extracted LaTeX)
\[','\]
','
Formula (extracted LaTeX)
\[= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle\]
= (sel, root=document) => Array.from(root.querySelectorAll(sel)); const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); const round1 = v => Math.round((v + Number.EPSILON) * 10) / 10; const toNum = v => { const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : NaN; }; // Unit system state let unitSystem = 'metric'; // 'metric' or 'us' const GAL_TO_L = 3.785411784; const OZ_TO_G = 28.349523125; const volumeInput = $('#volumeInput'); const volumeError = $('#volumeError'); const volumeUnitText = $('#volumeUnitText'); const gravityInput = $('#gravityInput'); const gravityError = $('#gravityError'); const hopRows = $('#hopRows'); const addHopBtn = $('#addHopBtn'); const totalIbuEl = $('#totalIbu'); const contribList = $('#contribList'); const gravityTipBtn = $('#gravityTipBtn'); const gravityTip = $('#gravityTip'); // Accessible tooltip toggling gravityTipBtn.addEventListener('click', () => { const expanded = gravityTipBtn.getAttribute('aria-expanded') === 'true'; gravityTipBtn.setAttribute('aria-expanded', String(!expanded)); gravityTip.setAttribute('aria-hidden', String(expanded)); if (!expanded) { gravityTip.focus?.(); } }); // Validation helpers function setError(el, msg) { el.textContent = msg || ''; } function validateVolume() { const v = toNum(volumeInput.value); if (!isFinite(v) || v <= 0) { setError(volumeError, 'Enter a final volume greater than 0.'); volumeInput.setAttribute('aria-invalid','true'); return false; } setError(volumeError, ''); volumeInput.removeAttribute('aria-invalid'); return true; } function validateGravity() { const g = toNum(gravityInput.value); if (!isFinite(g) || g < 1.0 || g > 1.2) { setError(gravityError, 'Enter a specific gravity between 1.000 and 1.200 (e.g., 1.050).'); gravityInput.setAttribute('aria-invalid','true'); return false; } setError(gravityError, ''); gravityInput.removeAttribute('aria-invalid'); return true; } // Temperature factor table for whirlpool (Celsius). Linear interpolation between points. const tempTableC = [ { t: 60, f: 0.08 }, { t: 65, f: 0.12 }, { t: 70, f: 0.18 }, { t: 75, f: 0.24 }, { t: 80, f: 0.32 }, { t: 85, f: 0.40 }, { t: 90, f: 0.55 }, { t: 95, f: 0.75 }, { t: 100, f: 1.00 } ]; function tempFactorC(Tc) { const T = clamp(Tc, 50, 100); for (let i=0; i<tempTableC.length-1; i++) { const a = tempTableC[i], b = tempTableC[i+1]; if (T >= a.t && T <= b.t) { const ratio = (T - a.t) / (b.t - a.t); return a.f + ratio * (b.f - a.f); } } if (T < tempTableC[0].t) return tempTableC[0].f * 0.75; return tempTableC[tempTableC.length-1].f; } function fToC(tf) { return (tf - 32) * (5/9); } // Tinseth utilization function tinsethUtilization(SG, minutes) { const t = Math.max(0, minutes); const bigness = 1.65 * Math.pow(0.000125, (SG - 1.0)); const boil = (1 - Math.exp(-0.04 * t)) / 4.15; return bigness * boil; // dimensionless } let rowCount = 0; function hopRowTemplate(idx) { const rowId = `hop-${idx}`; return ` <fieldset class="hop-row" id="${rowId}" data-row-index="${idx}"> <legend>Hop addition ${idx}</legend> <div class="row-grid"> <div class="form-group"> <label for="${rowId}-name">Hop name</label> <input type="text" id="${rowId}-name" class="input" placeholder="e.g., Citra"> </div> <div class="form-group"> <label for="${rowId}-aa">Alpha acid % *</label> <input type="number" id="${rowId}-aa" class="input" inputmode="decimal" step="any" min="0" max="30" aria-required="true" aria-describedby="${rowId}-aa-err"> <div id="${rowId}-aa-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-form">Form</label> <select id="${rowId}-form" class="select" aria-label="Hop form"> <option value="pellet" selected>Pellet</option> <option value="whole">Whole/Leaf</option> </select> </div> <div class="form-group"> <label for="${rowId}-type">Addition type</label> <select id="${rowId}-type" class="select" aria-label="Addition type"> <option value="boil" selected>Boil</option> <option value="whirlpool">Whirlpool/Steep</option> <option value="dry">Dry Hop (excluded from IBU)</option> </select> </div> <div class="form-group"> <label for="${rowId}-weight">Weight *</label> <div class="inline"> <input type="number" id="${rowId}-weight" class="input" inputmode="decimal" step="any" min="0" aria-required="true" aria-describedby="${rowId}-weight-err ${rowId}-wunit"> <div class="help-inline" id="${rowId}-wunit">${unitSystem === 'metric' ? 'g' : 'oz'}</div> </div> <div id="${rowId}-weight-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-time">Time (min) *</label> <input type="number" id="${rowId}-time" class="input" inputmode="numeric" step="1" min="0" aria-required="true" aria-describedby="${rowId}-time-err ${rowId}-timelabel"> <div id="${rowId}-timelabel" class="help-inline">Boil or steep time</div> <div id="${rowId}-time-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group"> <label for="${rowId}-temp">Temperature</label> <div class="inline"> <input type="number" id="${rowId}-temp" class="input" inputmode="decimal" step="any" min="0" aria-describedby="${rowId}-tunit ${rowId}-temp-err"> <div class="help-inline" id="${rowId}-tunit">${unitSystem === 'metric' ? '°C' : '°F'}</div> </div> <div id="${rowId}-temp-err" class="error" role="alert" aria-live="polite"></div> </div> <div class="form-group span-3" style="display:flex; justify-content:flex-end;"> <button type="button" class="btn btn-danger remove-hop" aria-label="Remove hop addition ${idx}">Remove</button> </div> </div> </fieldset> `; } function addHopRow(preset) { rowCount += 1; const wrapper = document.createElement('div'); wrapper.innerHTML = hopRowTemplate(rowCount); const el = wrapper.firstElementChild; hopRows.appendChild(el); // Defaults if (preset) { $(`#${el.id}-name`).value = preset.name || ''; $(`#${el.id}-aa`).value = preset.aa ?? ''; $(`#${el.id}-form`).value = preset.form || 'pellet'; $(`#${el.id}-type`).value = preset.type || 'boil'; $(`#${el.id}-weight`).value = preset.weight ?? ''; $(`#${el.id}-time`).value = preset.time ?? ''; $(`#${el.id}-temp`).value = preset.temp ?? ''; } else { // Sensible defaults for first row if (rowCount === 1) { $(`#${el.id}-aa`).value = 12; $(`#${el.id}-weight`).value = unitSystem === 'metric' ? 20 : 0.7; $(`#${el.id}-time`).value = 60; $(`#${el.id}-temp`).value = unitSystem === 'metric' ? 100 : 212; } } } function removeHopRow(fieldset) { fieldset?.remove(); compute(); } // Event delegation for rows hopRows.addEventListener('click', (e) => { const target = e.target; if (target.classList.contains('remove-hop')) { const fs = target.closest('fieldset.hop-row'); removeHopRow(fs); } }); // Validation for rows on blur hopRows.addEventListener('blur', (e) => { const input = e.target; if (!(input instanceof HTMLInputElement)) return; const fs = input.closest('fieldset.hop-row'); if (!fs) return; const id = input.id; if (id.endsWith('-aa')) { const v = toNum(input.value); const err = $(`#${fs.id}-aa-err`); if (!isFinite(v) || v <= 0 || v > 30) { err.textContent = 'Enter alpha acid between 0 and 30%.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-weight')) { const v = toNum(input.value); const err = $(`#${fs.id}-weight-err`); if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a hop weight greater than 0.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-time')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-time-err`); if (type === 'dry') { err.textContent = ''; input.removeAttribute('aria-invalid'); } else if (!isFinite(v) || v <= 0) { err.textContent = 'Enter a time greater than 0 minutes.'; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } if (id.endsWith('-temp')) { const type = $(`#${fs.id}-type`).value; const v = toNum(input.value); const err = $(`#${fs.id}-temp-err`); if (type === 'whirlpool') { const ok = isFinite(v) && v > 50 && v <= (unitSystem === 'metric' ? 100 : 212); if (!ok) { err.textContent = `Enter a realistic whirlpool temperature (${unitSystem==='metric'?'60–100 °C':'140–212 °F'}).`; input.setAttribute('aria-invalid','true'); } else { err.textContent=''; input.removeAttribute('aria-invalid'); } } else { err.textContent = ''; input.removeAttribute('aria-invalid'); } } compute(); }, true); // React to addition type to toggle labels hopRows.addEventListener('change', (e) => { const target = e.target; const fs = target.closest('fieldset.hop-row'); if (!fs) return; if (target.id.endsWith('-type')) { const type = target.value; const timeLabel = $(`#${fs.id}-timelabel`); const tInput = $(`#${fs.id}-time`); const tempInput = $(`#${fs.id}-temp`); if (type === 'boil') { timeLabel.textContent = 'Boil time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 100 : 212; } else if (type === 'whirlpool') { timeLabel.textContent = 'Steep time'; if (!tempInput.value) tempInput.value = unitSystem === 'metric' ? 80 : 176; } else { timeLabel.textContent = 'Duration (ignored for IBU)'; } } compute(); }); // Global inputs validation volumeInput.addEventListener('blur', () => { validateVolume(); compute(); }); gravityInput.addEventListener('blur', () => { validateGravity(); compute(); }); // Reactive calculation on input for smooth UX (INP-friendly) document.addEventListener('input', (e) => { const id = (e.target && e.target.id) || ''; if (id.startsWith('hop-') || id === 'volumeInput' || id === 'gravityInput') { compute(); } }); // Units toggle
Formula (extracted LaTeX)
\[('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];\]
('#hopRows fieldset.hop-row').forEach(fs => { $(`#${fs.id}-wunit`).textContent = unitSystem === 'metric' ? 'g' : 'oz'; $(`#${fs.id}-tunit`).textContent = unitSystem === 'metric' ? '°C' : '°F'; }); // Convert example defaults if empty temperature fields compute(); }); }); // Add hop row button addHopBtn.addEventListener('click', () => addHopRow()); // Core compute function compute() { const vol = toNum(volumeInput.value); const SG = toNum(gravityInput.value); const volL = unitSystem === 'metric' ? vol : (isFinite(vol) ? vol * GAL_TO_L : NaN); if (!isFinite(volL) || volL <= 0 || !isFinite(SG) || SG < 1.0) { totalIbuEl.textContent = '0.0'; contribList.innerHTML = ''; return; } let total = 0; const items = [];
Formula (extracted text)
Tinseth utilization (boil): U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15} IBU contribution (per addition): \mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l} Pellet vs. whole adjustment: U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases} Whirlpool/steep temperature factor: U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(G - 1)} \times \dfrac{1 - e^{-0.04\,t}}{4.15}
Formula (extracted text)
\mathrm{IBU} = \dfrac{w_g \times 1000 \times \alpha \times U}{V_l}
Formula (extracted text)
U_{\text{adj}} = U \times f_{\text{form}}, \quad f_{\text{form}} = \begin{cases} 1.10 & \text{pellet} \\ 1.00 & \text{whole/leaf} \end{cases}
Formula (extracted text)
U_{\text{whirlpool}} = U \times f_T(T), \quad f_T(100^{\circ}\mathrm{C}) \approx 1.00,\; f_T(90^{\circ}\mathrm{C}) \approx 0.55,\; f_T(80^{\circ}\mathrm{C}) \approx 0.32,\; f_T(70^{\circ}\mathrm{C}) \approx 0.18,\; f_T(60^{\circ}\mathrm{C}) \approx 0.08
Formula (extracted text)
U = 1.65 \times 0.000125^{(1.050 - 1)} \times \dfrac{1 - e^{-0.04 \times 60}}{4.15} \approx 0.246
Variables and units
  • No variables provided in audit spec.
Sources (authoritative):
Changelog
Version: 0.1.0-draft
Last code update: 2026-01-19
0.1.0-draft · 2026-01-19
  • Initial audit spec draft generated from HTML extraction (review required).
  • Verify formulas match the calculator engine and convert any text-only formulas to LaTeX.
  • Confirm sources are authoritative and relevant to the calculator methodology.
Verified by Ugo Candido on 2026-01-19
Profile · LinkedIn
Formulas

(Formulas preserved from original page content, if present.)

Version 0.1.0-draft
Citations

Add authoritative sources relevant to this calculator (standards bodies, manuals, official docs).

Changelog
  • 0.1.0-draft — 2026-01-19: Initial draft (review required).