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
+117
View File
@@ -431,6 +431,121 @@ def _fmt_point_scale(points: int | None) -> str:
return f"{points % 1000}"
def fermentation_curve(run: Run) -> list[dict]:
"""The run's gravity readings shaped for plotting.
Each point carries the reading date, the gravity, and how far through
the fermentation it was - the fraction of the total gravity drop that
had happened by that reading. That makes two runs with different
gravities directly comparable, which is the point of the chart.
"""
values = run.sg_values()
if len(values) < 2:
return []
og = values[0]
total_drop = og - values[-1]
if total_drop <= 0:
# No drop to measure progress against.
return []
points = []
for reading in run.readings:
if reading.sg_points is None:
continue
drop = og - reading.sg_points
points.append({
"date": reading.date.isoformat() if reading.date else None,
"sg": reading.sg_points,
"sg_decimal": _fmt_decimal(reading.sg_points),
"point_scale": _fmt_point_scale(reading.sg_points),
# 0.0 at the start, 1.0 at the final reading.
"progress": round(max(0.0, min(1.0, drop / total_drop)), 4),
"final": reading.final_reading,
"comment": reading.comment,
})
return points
def attenuation_percent(run: Run) -> float | None:
"""The apparent attenuation: how much of the original gravity was
fermented away.
This is the number a brewer actually compares between recipes, and it is
more informative than ABV alone because it normalises for how strong the
starting wort was.
Returns ``None`` when the figure would be meaningless:
- The original gravity was not above 1.000, so there is nothing to
attenuate.
- The final gravity is below 1.000. Beer cannot be less dense than
water, so a reading below 1.000 means the reading is wrong (a
mis-entry, or a hydrometer read at the wrong temperature). Run 12 in
the live data has a final gravity of 900, which would otherwise show
as a nonsense 338% attenuation. The run is reported separately by
:func:`implausible_runs` rather than being quietly folded into the
averages.
"""
og = run.original_gravity()
fg = run.final_gravity()
if og is None or fg is None or og <= 1000 or fg < 1000:
return None
attenuation = (og - fg) / (og - 1000) * 100
if attenuation > 100:
# Cannot ferment away more than the sugar that was there.
return None
return round(attenuation, 1)
def implausible_runs(runs: Iterable[Run]) -> list[dict]:
"""The runs whose gravity readings are physically impossible.
Beer cannot be less dense than water, so a final gravity below 1.000
means a reading is wrong. These runs are excluded from the averages and
listed on the analysis page instead of being silently included.
"""
problems = []
for run in runs:
og = run.original_gravity()
fg = run.final_gravity()
if fg is not None and fg < 1000:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"final gravity {fg / 1000:.3f} is below 1.000 — "
"beer cannot be less dense than water, so this reading "
"is probably wrong"
),
})
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
and (og - fg) / (og - 1000) * 100 > 100:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": "the gravity drop implies more than 100% attenuation",
})
return problems
def days_to_target(run: Run, target: int) -> int | None:
"""How many days the run took to reach *target* gravity.
Used to answer "how long does this usually take?", which is the question
a brewer actually has when planning the next batch.
"""
for reading in run.readings:
if reading.sg_points is not None and reading.sg_points <= target:
first = run.first_reading()
if first is None or first.date is None or reading.date is None:
return None
return days_between(first.date, reading.date)
return None
def run_summary(run: Run, today: date | None = None,
og_override: int | None = None) -> dict:
"""A flat dict describing a run, ready for a template or a JSON response."""
@@ -466,6 +581,8 @@ def run_summary(run: Run, today: date | None = None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"last_comment": run.comments()[-1] if run.comments() else "",
"attenuation": attenuation_percent(run),
"curve": fermentation_curve(run),
"outliers": [r.sg_points for r in run.outlier_readings()],
"readings": [
{