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
+221
View File
@@ -0,0 +1,221 @@
{% extends 'base.html' %}
{% block title %}Analysis — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Analysis</h1>
<p class="page__sub">How consistently the brews ferment, and how long each one takes.</p>
</div>
<div class="btn-row">
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
</div>
</div>
{% if not rows %}
<div class="card">
<div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<p><strong>No finished brews to analyse yet.</strong></p>
<p class="small">Once a run has a final reading it appears here.</p>
</div>
</div>
{% else %}
<div class="grid grid--4 mb-lg">
<div class="stat">
<div class="stat__label">Average attenuation</div>
<div class="stat__value">
{% if stats.avg_attenuation is not none %}{{ stats.avg_attenuation }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">of the original gravity fermented</div>
</div>
<div class="stat">
<div class="stat__label">Spread</div>
<div class="stat__value">
{% if stats.consistency %}±{{ stats.consistency.stdev }}pp{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.consistency %}
standard deviation of attenuation, lower is better
{% else %}
needs at least two brews
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Average brew time</div>
<div class="stat__value">
{% if stats.avg_days is not none %}{{ stats.avg_days }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">first reading to final reading</div>
</div>
<div class="stat">
<div class="stat__label">Time to 1.010</div>
<div class="stat__value">
{% if stats.avg_days_to_1010 is not none %}{{ stats.avg_days_to_1010 }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.reached_1010 %}
average of {{ stats.reached_1010 }} brew{{ '' if stats.reached_1010 == 1 else 's' }} that got there
{% else %}
no brew reached 1.010
{% endif %}
</div>
</div>
</div>
<div class="grid grid--2 mb-lg">
<section class="card">
<div class="card__head">
<h2 class="card__title">Attenuation by brew</h2>
<span class="card__hint">How much of the original gravity was fermented away</span>
</div>
<div class="card__body">
<canvas id="attenuation-chart" height="130"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Fermentation curves</h2>
<span class="card__hint">Progress through fermentation, 0% to 100%</span>
</div>
<div class="card__body">
<canvas id="curve-chart" height="130"></canvas>
</div>
</section>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Every finished brew</h2>
<span class="card__hint">{{ stats.count }} brews</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th class="num">Attenuation</th>
<th class="num">Days</th>
<th class="num">To 1.010</th>
<th>Finished</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="num">
{% if row.og_points is not none %}
{{ '%.3f' % (row.og_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% if suspect %}
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Readings that need a second look</h2>
<span class="card__hint">{{ suspect | length }} run{{ '' if suspect | length == 1 else 's' }} excluded from the averages</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
{% if stats.consistency %}
<section class="card">
<div class="card__head">
<h2 class="card__title">What the numbers say</h2>
</div>
<div class="card__body">
<p>
Across {{ stats.count }} finished brews the attenuation averages
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
deviation of <strong>{{ stats.consistency.stdev }} percentage
points</strong> and a total range of {{ stats.consistency.spread }}
percentage points.
{% if stats.consistency.stdev <= 3 %}
That is a tight spread — the process is repeatable.
{% elif stats.consistency.stdev <= 6 %}
That is a moderate spread — some variation between batches.
{% else %}
That is a wide spread — something in the process is not
consistent between batches.
{% endif %}
</p>
{% if stats.fastest and stats.slowest and stats.fastest != stats.slowest %}
<p>
The quickest brew was <strong>{{ stats.fastest.name }}</strong> at
{{ stats.fastest.days }} days; the longest was
<strong>{{ stats.slowest.name }}</strong> at
{{ stats.slowest.days }} days.
</p>
{% endif %}
{% if stats.avg_days_to_1010 %}
<p>
On average a brew reaches gravity 1.010 after
{{ stats.avg_days_to_1010 }} days — the number to plan around when
deciding when to check on a fermenter.
</p>
{% endif %}
</div>
</section>
{% endif %}
{% endif %}
{% endblock %}