The second review of PR #12 (after the suspect-run fix) found that the two BAR charts were still plotting runs they should not have been, while the curve chart and the tiles had been corrected. The page was contradicting itself. The ABV chart plotted the suspect run -------------------------------------- Run 12's impossible reading gives it an ABV of 18.64%. The chart's y axis went to 18.64, so the fifteen real bars were squashed into the bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same page showed the correct figure. The chart now leaves the suspect runs out. The attenuation chart plotted the active runs ---------------------------------------------- An active run's attenuation is measured against its CURRENT gravity, so a brew that has just started shows a 0% bar. That is the absence of a result, not a result, and it sat directly above the table of finished brews. The chart now leaves the active runs out. Verified against the live data: ABV chart bars: bars=15 max=8.53 (was 16 bars, max 18.64) attenuation chart bars: bars=15 min=76.2 (was 17 bars, min 0) A suspect active run no longer vanishes --------------------------------------- The API flags suspect runs over EVERY run, so the charts leave them out. But the analysis page only looked at the finished ones, so an active run with a bad reading would be dropped from the charts and never listed in "Readings that need a second look" - it would disappear with no explanation anywhere. The analysis page now lists suspect active runs too. The figures say what they are based on -------------------------------------- - The analysis prose counted the non-suspect runs, not the ones that actually contributed a figure to the mean. It now counts the latter. - The history page's "Average ABV — across finished brews" was averaging 15 of 16 without saying so, while the analysis page said "16 brews (15 counted)". It now says "across 15 of 16 finished brews". - `/api/runs/<run_id>` carries the `suspect` and `active` flags that `/api/runs` does. 148 tests (up from 142).
224 lines
8.8 KiB
HTML
224 lines
8.8 KiB
HTML
{% 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{% if stats.suspect_count %} ({{ stats.trusted_count }} counted){% endif %}</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{% if row.suspect %} class="is-suspect"{% endif %}>
|
|
<td class="mono">{{ row.beer_run_id }}</td>
|
|
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</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">
|
|
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
|
|
</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 {% if stats.suspect_count %}{{ stats.counted_attenuation }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} 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 %}
|