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:
2026-11-09 13:11:23 +10:00
parent 0785c11790
commit e1a9a7384f
7 changed files with 1023 additions and 3 deletions
+243
View File
@@ -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"
)
+62
View File
@@ -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