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": [
|
||||
{
|
||||
|
||||
@@ -152,8 +152,18 @@
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
// The dates are strings, so this axis stays
|
||||
// categorical - but it must be declared,
|
||||
// because Chart.js would otherwise infer
|
||||
// something else from the {x, y} points.
|
||||
type: "category",
|
||||
ticks: { maxRotation: 0, autoSkipPadding: 12 }
|
||||
},
|
||||
y: {
|
||||
reverse: true,
|
||||
// NOT reversed - see the fermentation curve.
|
||||
// Gravity falls as the beer ferments, so the
|
||||
// line must descend left to right.
|
||||
title: { display: true, text: "Specific gravity (points)" }
|
||||
}
|
||||
}
|
||||
@@ -206,7 +216,19 @@
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
|
||||
x: { ticks: { maxRotation: 45, minRotation: 0 } }
|
||||
x: {
|
||||
// The names are long and the chart is narrow,
|
||||
// so the labels are trimmed above and
|
||||
// rotated. autoSkip keeps them from
|
||||
// overlapping; the tooltip carries the
|
||||
// full name for every bar.
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -274,11 +296,279 @@
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ attenuation by brew */
|
||||
|
||||
function drawAttenuationChart() {
|
||||
var canvas = document.getElementById("attenuation-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
var done = runs.filter(function (r) {
|
||||
return r.attenuation !== null && r.attenuation !== undefined;
|
||||
});
|
||||
if (!done.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: done.map(function (r) {
|
||||
var name = r.canonical_name || ("Run " + r.beer_run_id);
|
||||
// Long names were clipped at the left edge of
|
||||
// the plot when rotated. Trim them to a
|
||||
// readable length; the tooltip and the table
|
||||
// below carry the full name.
|
||||
if (name.length > 18) {
|
||||
name = name.slice(0, 17) + "\u2026";
|
||||
}
|
||||
return name;
|
||||
}),
|
||||
datasets: [{
|
||||
label: "Attenuation %",
|
||||
data: done.map(function (r) { return r.attenuation; }),
|
||||
backgroundColor: withAlpha(ACCENT, 0.65),
|
||||
borderColor: ACCENT,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
return ctx.parsed.y.toFixed(1) + "% attenuated";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
// Attenuation cannot exceed 100%, so the axis
|
||||
// is capped there. Without the cap a single
|
||||
// bad reading would squash every other bar
|
||||
// into an unreadable stub.
|
||||
suggestedMax: 100,
|
||||
title: { display: true, text: "% attenuated" }
|
||||
},
|
||||
x: {
|
||||
// The names are long and the chart is narrow,
|
||||
// so the labels are trimmed above and
|
||||
// rotated. autoSkip keeps them from
|
||||
// overlapping; the tooltip carries the
|
||||
// full name for every bar.
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- fermentation curve overlay */
|
||||
|
||||
function drawCurveChart() {
|
||||
var canvas = document.getElementById("curve-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
var withCurve = runs.filter(function (r) {
|
||||
return r.curve && r.curve.length > 1;
|
||||
});
|
||||
if (!withCurve.length) { return; }
|
||||
|
||||
// Plotting every brew individually gives 16 near-identical
|
||||
// lines and an unreadable legend. Instead: a MEDIAN curve
|
||||
// with the min/max envelope behind it, which shows the
|
||||
// typical shape and the spread without the clutter.
|
||||
//
|
||||
// Each curve is the fraction of its own gravity drop
|
||||
// completed, so brews with different starting gravities can
|
||||
// be compared directly.
|
||||
var samples = 21; // 0%, 5%, ... 100%
|
||||
|
||||
// Interpolate each brew's curve onto the common progress axis.
|
||||
//
|
||||
// The brew's readings are at its own progress values (say
|
||||
// 0%, 40%, 100%), so each sample point is interpolated
|
||||
// BETWEEN the two readings that bracket it. Looking the
|
||||
// value up by an exact key only works when every brew
|
||||
// happened to be read at the same progress points, which is
|
||||
// why the first attempt produced three points.
|
||||
function interpolate(curve, pct) {
|
||||
if (!curve.length) { return null; }
|
||||
if (pct <= curve[0].x) { return curve[0].y; }
|
||||
var last = curve[curve.length - 1];
|
||||
if (pct >= last.x) { return last.y; }
|
||||
for (var k = 0; k < curve.length - 1; k++) {
|
||||
var a = curve[k];
|
||||
var b = curve[k + 1];
|
||||
if (pct >= a.x && pct <= b.x) {
|
||||
var span = b.x - a.x;
|
||||
if (span === 0) { return a.y; }
|
||||
return a.y + (b.y - a.y) * ((pct - a.x) / span);
|
||||
}
|
||||
}
|
||||
return last.y;
|
||||
}
|
||||
|
||||
// The raw readings of each brew, sorted by progress.
|
||||
var rawCurves = withCurve.map(function (r) {
|
||||
var points = r.curve.map(function (p) {
|
||||
return {
|
||||
x: p.progress * 100,
|
||||
y: parseFloat(p.sg_decimal)
|
||||
};
|
||||
}).filter(function (p) { return !isNaN(p.y); });
|
||||
points.sort(function (a, b) { return a.x - b.x; });
|
||||
return points;
|
||||
}).filter(function (c) { return c.length > 1; });
|
||||
|
||||
if (!rawCurves.length) { return; }
|
||||
|
||||
// The interpolated value of every brew at every sample point.
|
||||
var sampled = rawCurves.map(function (curve) {
|
||||
var out = [];
|
||||
for (var i = 0; i < samples; i++) {
|
||||
var pct = (i / (samples - 1)) * 100;
|
||||
var y = interpolate(curve, pct);
|
||||
if (y === null) { continue; }
|
||||
out.push({ x: pct, y: y });
|
||||
}
|
||||
return out;
|
||||
}).filter(function (c) { return c.length > 1; });
|
||||
|
||||
if (!sampled.length) { return; }
|
||||
|
||||
// The envelope: the highest and lowest gravity at each
|
||||
// progress point.
|
||||
var envelope = [];
|
||||
var median = [];
|
||||
for (var i = 0; i < samples; i++) {
|
||||
var pct = (i / (samples - 1)) * 100;
|
||||
var values = [];
|
||||
sampled.forEach(function (curve) {
|
||||
var match = curve.filter(function (p) {
|
||||
return Math.abs(p.x - pct) < 0.01;
|
||||
});
|
||||
if (match.length) { values.push(match[0].y); }
|
||||
});
|
||||
if (!values.length) { continue; }
|
||||
values.sort(function (a, b) { return a - b; });
|
||||
envelope.push({
|
||||
x: pct,
|
||||
y: values[0],
|
||||
yMax: values[values.length - 1]
|
||||
});
|
||||
var mid = Math.floor(values.length / 2);
|
||||
median.push({
|
||||
x: pct,
|
||||
y: values.length % 2
|
||||
? values[mid]
|
||||
: (values[mid - 1] + values[mid]) / 2
|
||||
});
|
||||
}
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: "Range (min–max)",
|
||||
data: envelope,
|
||||
borderColor: withAlpha(ACCENT, 0.35),
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
fill: "+1",
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: "Median brew",
|
||||
data: median,
|
||||
borderColor: ACCENT_BRIGHT,
|
||||
backgroundColor: withAlpha(ACCENT_BRIGHT, 0.15),
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT_BRIGHT,
|
||||
fill: false,
|
||||
tension: 0.3
|
||||
}
|
||||
]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
var point = ctx.parsed;
|
||||
if (ctx.datasetIndex === 0) {
|
||||
return "range " + point.y +
|
||||
" to " + (point.yMax || point.y);
|
||||
}
|
||||
return "median " + point.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
// The x values are NUMERIC (0-100), so the
|
||||
// axis must be linear. Chart.js defaults a
|
||||
// line chart's x-axis to category, which
|
||||
// collapses every point onto index 0 and
|
||||
// renders the curve as a sliver at the
|
||||
// origin.
|
||||
type: "linear",
|
||||
title: { display: true, text: "% of gravity drop" },
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { stepSize: 10 }
|
||||
},
|
||||
y: {
|
||||
// NOT reversed. Specific gravity falls as
|
||||
// the beer ferments, and the scale puts the
|
||||
// HIGH reading at the top by default, so the
|
||||
// curve descends left to right - the way a
|
||||
// brewer reads a fermentation. With
|
||||
// reverse:true the curve climbed instead,
|
||||
// which read as gravity rising.
|
||||
title: { display: true, text: "Specific gravity" }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
function init() {
|
||||
drawSparklines();
|
||||
drawRunChart();
|
||||
drawAbvChart();
|
||||
drawMonthsChart();
|
||||
drawAttenuationChart();
|
||||
drawCurveChart();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -7,7 +7,8 @@ from datetime import date
|
||||
|
||||
from flask import Blueprint, redirect, render_template, request, session, url_for
|
||||
|
||||
from brewing import Run, is_plausible_sg, run_summary
|
||||
from brewing import (Run, attenuation_percent, days_to_target, implausible_runs,
|
||||
is_plausible_sg, run_summary)
|
||||
|
||||
bp = Blueprint("analysis", __name__)
|
||||
|
||||
@@ -32,6 +33,90 @@ def login_required(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
@bp.route("/analysis")
|
||||
@login_required
|
||||
def deep():
|
||||
"""The deeper analysis: how the brews compare, and how long they take.
|
||||
|
||||
This is the page that answers the questions the history page cannot:
|
||||
how consistent is my attenuation, which brew fermented fastest, and what
|
||||
do the gravity curves look like side by side.
|
||||
"""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
finished = [r for r in runs if not r.is_active()]
|
||||
|
||||
# Runs whose gravity readings are physically impossible are excluded from
|
||||
# the averages and listed separately, rather than being silently folded
|
||||
# in (a final gravity below 1.000 would otherwise show as a 338%
|
||||
# attenuation).
|
||||
suspect = implausible_runs(finished)
|
||||
|
||||
rows = []
|
||||
for run in finished:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = _decorate(run, metadata)
|
||||
rows.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": meta.get("name") or run.display_name(),
|
||||
"final_date": summary["final_date"],
|
||||
"first_date": summary["first_date"],
|
||||
"og_points": summary["og_points"],
|
||||
"fg_points": summary["fg_points"],
|
||||
"abv": summary["abv"],
|
||||
"attenuation": attenuation_percent(run),
|
||||
"days": summary["days_in_fermenter"],
|
||||
"days_to_1010": days_to_target(run, 1010),
|
||||
"days_to_1015": days_to_target(run, 1015),
|
||||
"readings": summary["reading_count"],
|
||||
"curve": summary["curve"],
|
||||
})
|
||||
|
||||
# The rows with a usable curve, for the overlay chart.
|
||||
curves = [r for r in rows if r["curve"]]
|
||||
|
||||
attenuations = [r["attenuation"] for r in rows if r["attenuation"] is not None]
|
||||
durations = [r["days"] for r in rows if r["days"] is not None]
|
||||
to_1010 = [r["days_to_1010"] for r in rows if r["days_to_1010"] is not None]
|
||||
|
||||
# --- consistency ------------------------------------------------------- #
|
||||
# How much the attenuation varies. A tight spread means the process is
|
||||
# repeatable; a wide one means something is inconsistent.
|
||||
consistency = None
|
||||
if len(attenuations) >= 2:
|
||||
mean = sum(attenuations) / len(attenuations)
|
||||
variance = sum((a - mean) ** 2 for a in attenuations) / len(attenuations)
|
||||
consistency = {
|
||||
"mean": round(mean, 1),
|
||||
"stdev": round(variance ** 0.5, 1),
|
||||
"spread": round(max(attenuations) - min(attenuations), 1),
|
||||
}
|
||||
|
||||
fastest = min((r for r in rows if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
slowest = max((r for r in rows if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
|
||||
return render_template(
|
||||
"analysis.html",
|
||||
rows=rows,
|
||||
curves=curves,
|
||||
suspect=suspect,
|
||||
stats={
|
||||
"count": len(rows),
|
||||
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
||||
if attenuations else None,
|
||||
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
||||
"avg_days_to_1010": _fmt_days(sum(to_1010) / len(to_1010)) if to_1010 else None,
|
||||
"reached_1010": len(to_1010),
|
||||
"consistency": consistency,
|
||||
"fastest": fastest,
|
||||
"slowest": slowest,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _month_range(first: str, last: str) -> list[str]:
|
||||
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user