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:
@@ -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": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user