feat(analysis): attenuation, fermentation curves and brew timings
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.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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"
|
||||
)
|
||||
@@ -639,3 +639,65 @@ def test_month_range_covers_a_year_boundary():
|
||||
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||
]
|
||||
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- deep analysis #
|
||||
|
||||
def test_the_analysis_page_renders(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
||||
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "Ginger Beer" in body
|
||||
# The attenuation figures and the summary sentence.
|
||||
assert "80.0%" in body
|
||||
assert "attenuation" in body.lower()
|
||||
|
||||
|
||||
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
|
||||
"""An unfinished brew has no final reading, so it is not comparable."""
|
||||
seed_readings(repo, [
|
||||
("1", "Finished", "1050", "2026-01-01", False, ""),
|
||||
("1", "Finished", "1010", "2026-01-20", True, ""),
|
||||
("2", "Still Going", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
body = logged_in.get("/analysis").data.decode()
|
||||
assert "Finished" in body
|
||||
assert "Still Going" not in body
|
||||
|
||||
|
||||
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
|
||||
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
assert response.status_code == 200
|
||||
assert b"No finished brews" in response.data
|
||||
|
||||
|
||||
def test_the_analysis_page_is_protected(client):
|
||||
response = client.get("/analysis")
|
||||
assert response.status_code == 302
|
||||
assert "/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
|
||||
"""The charts fetch the curve data from the API."""
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||
])
|
||||
|
||||
payload = logged_in.get("/api/runs").get_json()
|
||||
entry = payload[0]
|
||||
assert entry["attenuation"] == 80.0
|
||||
assert len(entry["curve"]) == 2
|
||||
assert entry["curve"][0]["progress"] == 0.0
|
||||
assert entry["curve"][1]["progress"] == 1.0
|
||||
|
||||
Reference in New Issue
Block a user