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:
@@ -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 %}
|
||||
@@ -23,6 +23,8 @@
|
||||
href="{{ url_for('analysis.active') }}">Active Beers</a>
|
||||
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.history') }}">History</a>
|
||||
<a class="nav__link {% if current_path == '/analysis' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.deep') }}">Analysis</a>
|
||||
</nav>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
||||
|
||||
Reference in New Issue
Block a user