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:
@@ -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