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}"
|
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,
|
def run_summary(run: Run, today: date | None = None,
|
||||||
og_override: int | None = None) -> dict:
|
og_override: int | None = None) -> dict:
|
||||||
"""A flat dict describing a run, ready for a template or a JSON response."""
|
"""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),
|
"days_in_fermenter": run.days_in_fermenter(today),
|
||||||
"comments": run.comments(),
|
"comments": run.comments(),
|
||||||
"last_comment": run.comments()[-1] if run.comments() else "",
|
"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()],
|
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||||
"readings": [
|
"readings": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -152,8 +152,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
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: {
|
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)" }
|
title: { display: true, text: "Specific gravity (points)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +216,19 @@
|
|||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
|
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); });
|
.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() {
|
function init() {
|
||||||
drawSparklines();
|
drawSparklines();
|
||||||
drawRunChart();
|
drawRunChart();
|
||||||
drawAbvChart();
|
drawAbvChart();
|
||||||
drawMonthsChart();
|
drawMonthsChart();
|
||||||
|
drawAttenuationChart();
|
||||||
|
drawCurveChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
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>
|
href="{{ url_for('analysis.active') }}">Active Beers</a>
|
||||||
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
||||||
href="{{ url_for('analysis.history') }}">History</a>
|
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>
|
</nav>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
<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 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__)
|
bp = Blueprint("analysis", __name__)
|
||||||
|
|
||||||
@@ -32,6 +33,90 @@ def login_required(fn):
|
|||||||
return wrapper
|
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]:
|
def _month_range(first: str, last: str) -> list[str]:
|
||||||
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"""Tests for the deeper analysis: attenuation, fermentation curves, timings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from brewing import (Run, attenuation_percent, days_to_target,
|
||||||
|
fermentation_curve)
|
||||||
|
|
||||||
|
|
||||||
|
def _doc(run_id="1", beer_type="Ale", sg="1045", date_="2026-01-01",
|
||||||
|
final="False", comment=""):
|
||||||
|
return {
|
||||||
|
"_id": f"id-{run_id}-{date_}-{sg}",
|
||||||
|
"beer_run_id": run_id,
|
||||||
|
"beer_type": beer_type,
|
||||||
|
"sg": sg,
|
||||||
|
"date": date_,
|
||||||
|
"final_reading": final,
|
||||||
|
"comment": comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ attenuation #
|
||||||
|
|
||||||
|
def test_attenuation_percent():
|
||||||
|
"""Attenuation is the fraction of the original gravity fermented away."""
|
||||||
|
# OG 1050, FG 1010: 40 of the 50 points above 1.000 went away.
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_percent_of_a_full_fermentation():
|
||||||
|
"""A wort that finishes at 1.000 has 100% attenuation."""
|
||||||
|
run = Run.from_docs("2", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1000", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_percent_needs_a_gravity_above_1000():
|
||||||
|
"""A run that started at or below 1.000 has no meaningful attenuation."""
|
||||||
|
run = Run.from_docs("3", [
|
||||||
|
_doc(sg="1000", date_="2026-01-01"),
|
||||||
|
_doc(sg="0995", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_percent_of_an_unfinished_run_uses_its_current_gravity():
|
||||||
|
"""The current gravity stands in for the final one until the run ends."""
|
||||||
|
run = Run.from_docs("4", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1020", date_="2026-01-10"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- fermentation curve #
|
||||||
|
|
||||||
|
def test_fermentation_curve_is_empty_for_a_single_reading():
|
||||||
|
"""One reading is no curve."""
|
||||||
|
run = Run.from_docs("5", [_doc(sg="1045", date_="2026-01-01")])
|
||||||
|
assert fermentation_curve(run) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fermentation_curve_progresses_from_zero_to_one():
|
||||||
|
"""The curve is the fraction of the gravity drop completed."""
|
||||||
|
run = Run.from_docs("6", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1030", date_="2026-01-10"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
curve = fermentation_curve(run)
|
||||||
|
|
||||||
|
assert len(curve) == 3
|
||||||
|
assert curve[0]["progress"] == 0.0
|
||||||
|
assert curve[1]["progress"] == 0.5
|
||||||
|
assert curve[2]["progress"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fermentation_curve_carries_the_reading_details():
|
||||||
|
run = Run.from_docs("7", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01", comment="brew day"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True", comment="kegging"),
|
||||||
|
])
|
||||||
|
curve = fermentation_curve(run)
|
||||||
|
|
||||||
|
assert curve[0]["date"] == "2026-01-01"
|
||||||
|
assert curve[0]["sg_decimal"] == "1.050"
|
||||||
|
assert curve[0]["point_scale"] == "50"
|
||||||
|
assert curve[0]["comment"] == "brew day"
|
||||||
|
assert curve[1]["final"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_fermentation_curve_is_empty_when_the_gravity_never_falls():
|
||||||
|
"""A run that only goes up has no drop to measure progress against."""
|
||||||
|
run = Run.from_docs("8", [
|
||||||
|
_doc(sg="1010", date_="2026-01-01"),
|
||||||
|
_doc(sg="1020", date_="2026-01-10", final="True"),
|
||||||
|
])
|
||||||
|
assert fermentation_curve(run) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- days to target #
|
||||||
|
|
||||||
|
def test_days_to_target():
|
||||||
|
"""How long the brew took to reach a given gravity."""
|
||||||
|
run = Run.from_docs("9", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1020", date_="2026-01-08"),
|
||||||
|
_doc(sg="1005", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
# 1020 is still above 1015, so the run does not reach 1015 until the
|
||||||
|
# third reading, 19 days in.
|
||||||
|
assert days_to_target(run, 1015) == 19
|
||||||
|
assert days_to_target(run, 1010) == 19
|
||||||
|
# 1020 is below 1025, so that target is reached on the second reading.
|
||||||
|
assert days_to_target(run, 1025) == 7
|
||||||
|
assert days_to_target(run, 1000) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_to_target_returns_none_when_never_reached():
|
||||||
|
run = Run.from_docs("10", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1040", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert days_to_target(run, 1010) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- summary #
|
||||||
|
|
||||||
|
def test_run_summary_includes_the_new_analysis_fields():
|
||||||
|
from brewing import run_summary
|
||||||
|
|
||||||
|
run = Run.from_docs("11", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
summary = run_summary(run)
|
||||||
|
|
||||||
|
assert summary["attenuation"] == 80.0
|
||||||
|
assert len(summary["curve"]) == 2
|
||||||
|
assert summary["curve"][0]["progress"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_is_none_for_a_final_gravity_below_water():
|
||||||
|
"""Beer cannot be less dense than water. Run 12 in the live data has a
|
||||||
|
final gravity of 900, which would otherwise show as 338% attenuation."""
|
||||||
|
run = Run.from_docs("12", [
|
||||||
|
_doc(sg="1042", date_="2025-10-05"),
|
||||||
|
_doc(sg="900", date_="2025-11-30", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_is_none_when_it_would_exceed_100_percent():
|
||||||
|
run = Run.from_docs("13", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1000", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
# 1050 -> 1000 is exactly 100%, which is the limit but still valid.
|
||||||
|
assert attenuation_percent(run) == 100.0
|
||||||
|
|
||||||
|
run2 = Run.from_docs("14", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert attenuation_percent(run2) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_implausible_runs_are_identified():
|
||||||
|
from brewing import implausible_runs
|
||||||
|
|
||||||
|
good = Run.from_docs("1", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
bad = Run.from_docs("12", [
|
||||||
|
_doc(sg="1042", date_="2025-10-05"),
|
||||||
|
_doc(sg="900", date_="2025-11-30", final="True"),
|
||||||
|
])
|
||||||
|
|
||||||
|
problems = implausible_runs([good, bad])
|
||||||
|
assert len(problems) == 1
|
||||||
|
assert problems[0]["beer_run_id"] == "12"
|
||||||
|
assert problems[0]["fg_points"] == 900
|
||||||
|
assert "below 1.000" in problems[0]["problem"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_implausible_runs_ignores_good_runs():
|
||||||
|
from brewing import implausible_runs
|
||||||
|
|
||||||
|
good = Run.from_docs("1", [
|
||||||
|
_doc(sg="1050", date_="2026-01-01"),
|
||||||
|
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||||
|
])
|
||||||
|
assert implausible_runs([good]) == []
|
||||||
|
|
||||||
|
# ------------------------------------------------------- chart rendering #
|
||||||
|
|
||||||
|
def test_charts_do_not_reverse_the_gravity_axis():
|
||||||
|
"""Gravity must FALL on screen as the beer ferments.
|
||||||
|
|
||||||
|
The gravity charts originally set `reverse: true` on the y axis, which
|
||||||
|
puts the HIGH reading at the bottom of the plot - so the fermentation
|
||||||
|
curve climbed left to right and read as gravity *rising*. A brewer
|
||||||
|
reads a fermentation as a descending line, so the axis must not be
|
||||||
|
reversed. The point-scale run chart had the same bug.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert "reverse: true" not in charts_js, (
|
||||||
|
"a gravity chart is reversing the y axis; the fermentation curve "
|
||||||
|
"would climb instead of descend"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attenuation_chart_truncates_long_names():
|
||||||
|
"""Long beer names were clipped at the left edge of the plot.
|
||||||
|
|
||||||
|
The rotated x-axis labels were cut off ("...x crying out loud"), so the
|
||||||
|
names are trimmed to a readable length before they are drawn. The
|
||||||
|
tooltip and the table below carry the full name.
|
||||||
|
"""
|
||||||
|
charts_js = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
assert "name.length > 18" in charts_js, (
|
||||||
|
"the attenuation chart is not trimming long beer names"
|
||||||
|
)
|
||||||
@@ -639,3 +639,65 @@ def test_month_range_covers_a_year_boundary():
|
|||||||
"2025-11", "2025-12", "2026-01", "2026-02",
|
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||||
]
|
]
|
||||||
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- deep analysis #
|
||||||
|
|
||||||
|
def test_the_analysis_page_renders(logged_in, repo):
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||||
|
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
||||||
|
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/analysis")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.data.decode()
|
||||||
|
assert "Pale Ale" in body
|
||||||
|
assert "Ginger Beer" in body
|
||||||
|
# The attenuation figures and the summary sentence.
|
||||||
|
assert "80.0%" in body
|
||||||
|
assert "attenuation" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
|
||||||
|
"""An unfinished brew has no final reading, so it is not comparable."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Finished", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Finished", "1010", "2026-01-20", True, ""),
|
||||||
|
("2", "Still Going", "1045", "2026-02-01", False, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
body = logged_in.get("/analysis").data.decode()
|
||||||
|
assert "Finished" in body
|
||||||
|
assert "Still Going" not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
|
||||||
|
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
|
||||||
|
|
||||||
|
response = logged_in.get("/analysis")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"No finished brews" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_analysis_page_is_protected(client):
|
||||||
|
response = client.get("/analysis")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
|
||||||
|
"""The charts fetch the curve data from the API."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
payload = logged_in.get("/api/runs").get_json()
|
||||||
|
entry = payload[0]
|
||||||
|
assert entry["attenuation"] == 80.0
|
||||||
|
assert len(entry["curve"]) == 2
|
||||||
|
assert entry["curve"][0]["progress"] == 0.0
|
||||||
|
assert entry["curve"][1]["progress"] == 1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user