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
+292 -2
View File
@@ -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") {