Adds the analysis page the old app never had: how consistently the brews
ferment, and how long each one takes.
What it shows
-------------
- **Attenuation by brew** - what fraction of the original gravity was
fermented away, for every finished brew.
- **Fermentation curves** - the typical shape of a fermentation, drawn as a
median curve with the min-max envelope behind it. Plotting every brew
individually gave 16 near-identical lines and an unreadable legend.
- **Every finished brew** - OG, FG, ABV, attenuation, days in the fermenter,
days to reach 1.010, and the finish date.
- **Readings that need a second look** - readings outside the plausible
hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
average time to 1.010.
Data quality
------------
One live run (run 12) has a final gravity of 900, which worked out as 338%
attenuation and dragged the average to 103.2%. That is not a real result,
so:
- Attenuation is rejected when the final gravity is not physically possible
for the original gravity.
- Runs that fail that check are EXCLUDED from the averages rather than
silently distorting them, and are listed on the page as needing review.
- The average now reads 87.5% +/- 7.4 percentage points across 16 brews.
Chart bugs found while building this
------------------------------------
- **The gravity charts had their y axis reversed.** With `reverse: true`
the high reading sits at the BOTTOM, so the fermentation curve climbed
left to right - it read as gravity *rising* as the beer fermented. A
brewer reads a fermentation as a descending line. The point-scale run
chart on the run detail page had the same bug. Both are fixed.
- **The curve chart's x axis was treated as categorical.** The x values are
numeric (0-100), and Chart.js defaults a line chart's x axis to
category, which collapsed every point onto index 0 and rendered the
curve as a sliver at the origin. The axis is now explicitly linear.
- **The curve was interpolated by exact key lookup**, so it only worked when
every brew happened to be read at the same progress points. It now
interpolates between a brew's actual readings, and the curve carries 21
points across the full range.
- **Long beer names were clipped** at the left edge of the attenuation
chart ("...x crying out loud"). Names are trimmed to 18 characters with
an ellipsis; the tooltip and the table carry the full name.
Tests
-----
127 (up from 105). The two chart regressions are covered by reading
`charts.js` and asserting the gravity axis is not reversed and that long
names are trimmed - these are exactly the kind of change that silently
reverts, and they are invisible to a screenshot test.
244 lines
8.1 KiB
Python
244 lines
8.1 KiB
Python
"""Tests for the deeper analysis: attenuation, fermentation curves, timings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from brewing import (Run, attenuation_percent, days_to_target,
|
|
fermentation_curve)
|
|
|
|
|
|
def _doc(run_id="1", beer_type="Ale", sg="1045", date_="2026-01-01",
|
|
final="False", comment=""):
|
|
return {
|
|
"_id": f"id-{run_id}-{date_}-{sg}",
|
|
"beer_run_id": run_id,
|
|
"beer_type": beer_type,
|
|
"sg": sg,
|
|
"date": date_,
|
|
"final_reading": final,
|
|
"comment": comment,
|
|
}
|
|
|
|
|
|
# ------------------------------------------------------------ attenuation #
|
|
|
|
def test_attenuation_percent():
|
|
"""Attenuation is the fraction of the original gravity fermented away."""
|
|
# OG 1050, FG 1010: 40 of the 50 points above 1.000 went away.
|
|
run = Run.from_docs("1", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
assert attenuation_percent(run) == 80.0
|
|
|
|
|
|
def test_attenuation_percent_of_a_full_fermentation():
|
|
"""A wort that finishes at 1.000 has 100% attenuation."""
|
|
run = Run.from_docs("2", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1000", date_="2026-01-20", final="True"),
|
|
])
|
|
assert attenuation_percent(run) == 100.0
|
|
|
|
|
|
def test_attenuation_percent_needs_a_gravity_above_1000():
|
|
"""A run that started at or below 1.000 has no meaningful attenuation."""
|
|
run = Run.from_docs("3", [
|
|
_doc(sg="1000", date_="2026-01-01"),
|
|
_doc(sg="0995", date_="2026-01-20", final="True"),
|
|
])
|
|
assert attenuation_percent(run) is None
|
|
|
|
|
|
def test_attenuation_percent_of_an_unfinished_run_uses_its_current_gravity():
|
|
"""The current gravity stands in for the final one until the run ends."""
|
|
run = Run.from_docs("4", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1020", date_="2026-01-10"),
|
|
])
|
|
assert attenuation_percent(run) == 60.0
|
|
|
|
|
|
# ------------------------------------------------------- fermentation curve #
|
|
|
|
def test_fermentation_curve_is_empty_for_a_single_reading():
|
|
"""One reading is no curve."""
|
|
run = Run.from_docs("5", [_doc(sg="1045", date_="2026-01-01")])
|
|
assert fermentation_curve(run) == []
|
|
|
|
|
|
def test_fermentation_curve_progresses_from_zero_to_one():
|
|
"""The curve is the fraction of the gravity drop completed."""
|
|
run = Run.from_docs("6", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1030", date_="2026-01-10"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
curve = fermentation_curve(run)
|
|
|
|
assert len(curve) == 3
|
|
assert curve[0]["progress"] == 0.0
|
|
assert curve[1]["progress"] == 0.5
|
|
assert curve[2]["progress"] == 1.0
|
|
|
|
|
|
def test_fermentation_curve_carries_the_reading_details():
|
|
run = Run.from_docs("7", [
|
|
_doc(sg="1050", date_="2026-01-01", comment="brew day"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True", comment="kegging"),
|
|
])
|
|
curve = fermentation_curve(run)
|
|
|
|
assert curve[0]["date"] == "2026-01-01"
|
|
assert curve[0]["sg_decimal"] == "1.050"
|
|
assert curve[0]["point_scale"] == "50"
|
|
assert curve[0]["comment"] == "brew day"
|
|
assert curve[1]["final"] is True
|
|
|
|
|
|
def test_fermentation_curve_is_empty_when_the_gravity_never_falls():
|
|
"""A run that only goes up has no drop to measure progress against."""
|
|
run = Run.from_docs("8", [
|
|
_doc(sg="1010", date_="2026-01-01"),
|
|
_doc(sg="1020", date_="2026-01-10", final="True"),
|
|
])
|
|
assert fermentation_curve(run) == []
|
|
|
|
|
|
# ------------------------------------------------------------- days to target #
|
|
|
|
def test_days_to_target():
|
|
"""How long the brew took to reach a given gravity."""
|
|
run = Run.from_docs("9", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1020", date_="2026-01-08"),
|
|
_doc(sg="1005", date_="2026-01-20", final="True"),
|
|
])
|
|
# 1020 is still above 1015, so the run does not reach 1015 until the
|
|
# third reading, 19 days in.
|
|
assert days_to_target(run, 1015) == 19
|
|
assert days_to_target(run, 1010) == 19
|
|
# 1020 is below 1025, so that target is reached on the second reading.
|
|
assert days_to_target(run, 1025) == 7
|
|
assert days_to_target(run, 1000) is None
|
|
|
|
|
|
def test_days_to_target_returns_none_when_never_reached():
|
|
run = Run.from_docs("10", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1040", date_="2026-01-20", final="True"),
|
|
])
|
|
assert days_to_target(run, 1010) is None
|
|
|
|
|
|
# ------------------------------------------------------------------- summary #
|
|
|
|
def test_run_summary_includes_the_new_analysis_fields():
|
|
from brewing import run_summary
|
|
|
|
run = Run.from_docs("11", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
summary = run_summary(run)
|
|
|
|
assert summary["attenuation"] == 80.0
|
|
assert len(summary["curve"]) == 2
|
|
assert summary["curve"][0]["progress"] == 0.0
|
|
|
|
|
|
def test_attenuation_is_none_for_a_final_gravity_below_water():
|
|
"""Beer cannot be less dense than water. Run 12 in the live data has a
|
|
final gravity of 900, which would otherwise show as 338% attenuation."""
|
|
run = Run.from_docs("12", [
|
|
_doc(sg="1042", date_="2025-10-05"),
|
|
_doc(sg="900", date_="2025-11-30", final="True"),
|
|
])
|
|
assert attenuation_percent(run) is None
|
|
|
|
|
|
def test_attenuation_is_none_when_it_would_exceed_100_percent():
|
|
run = Run.from_docs("13", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1000", date_="2026-01-20", final="True"),
|
|
])
|
|
# 1050 -> 1000 is exactly 100%, which is the limit but still valid.
|
|
assert attenuation_percent(run) == 100.0
|
|
|
|
run2 = Run.from_docs("14", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
assert attenuation_percent(run2) == 80.0
|
|
|
|
|
|
def test_implausible_runs_are_identified():
|
|
from brewing import implausible_runs
|
|
|
|
good = Run.from_docs("1", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
bad = Run.from_docs("12", [
|
|
_doc(sg="1042", date_="2025-10-05"),
|
|
_doc(sg="900", date_="2025-11-30", final="True"),
|
|
])
|
|
|
|
problems = implausible_runs([good, bad])
|
|
assert len(problems) == 1
|
|
assert problems[0]["beer_run_id"] == "12"
|
|
assert problems[0]["fg_points"] == 900
|
|
assert "below 1.000" in problems[0]["problem"]
|
|
|
|
|
|
def test_implausible_runs_ignores_good_runs():
|
|
from brewing import implausible_runs
|
|
|
|
good = Run.from_docs("1", [
|
|
_doc(sg="1050", date_="2026-01-01"),
|
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
|
])
|
|
assert implausible_runs([good]) == []
|
|
|
|
# ------------------------------------------------------- chart rendering #
|
|
|
|
def test_charts_do_not_reverse_the_gravity_axis():
|
|
"""Gravity must FALL on screen as the beer ferments.
|
|
|
|
The gravity charts originally set `reverse: true` on the y axis, which
|
|
puts the HIGH reading at the bottom of the plot - so the fermentation
|
|
curve climbed left to right and read as gravity *rising*. A brewer
|
|
reads a fermentation as a descending line, so the axis must not be
|
|
reversed. The point-scale run chart had the same bug.
|
|
"""
|
|
charts_js = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
|
).read_text()
|
|
|
|
assert "reverse: true" not in charts_js, (
|
|
"a gravity chart is reversing the y axis; the fermentation curve "
|
|
"would climb instead of descend"
|
|
)
|
|
|
|
|
|
def test_attenuation_chart_truncates_long_names():
|
|
"""Long beer names were clipped at the left edge of the plot.
|
|
|
|
The rotated x-axis labels were cut off ("...x crying out loud"), so the
|
|
names are trimmed to a readable length before they are drawn. The
|
|
tooltip and the table below carry the full name.
|
|
"""
|
|
charts_js = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
|
).read_text()
|
|
|
|
assert "name.length > 18" in charts_js, (
|
|
"the attenuation chart is not trimming long beer names"
|
|
)
|