From e1a9a7384fc1907b5861ce3cb4a3d55838a26f39 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 13:11:23 +1000 Subject: [PATCH] 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. --- src/flask/brewing.py | 117 ++++++++++++ src/flask/static/js/charts.js | 294 +++++++++++++++++++++++++++++- src/flask/templates/analysis.html | 221 ++++++++++++++++++++++ src/flask/templates/base.html | 2 + src/flask/views/analysis.py | 87 ++++++++- tests/test_analysis.py | 243 ++++++++++++++++++++++++ tests/test_app.py | 62 +++++++ 7 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 src/flask/templates/analysis.html create mode 100644 tests/test_analysis.py diff --git a/src/flask/brewing.py b/src/flask/brewing.py index 800bec2..32126bd 100644 --- a/src/flask/brewing.py +++ b/src/flask/brewing.py @@ -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": [ { diff --git a/src/flask/static/js/charts.js b/src/flask/static/js/charts.js index 0d874d9..f90f2d5 100644 --- a/src/flask/static/js/charts.js +++ b/src/flask/static/js/charts.js @@ -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") { diff --git a/src/flask/templates/analysis.html b/src/flask/templates/analysis.html new file mode 100644 index 0000000..d0a78d3 --- /dev/null +++ b/src/flask/templates/analysis.html @@ -0,0 +1,221 @@ +{% extends 'base.html' %} + +{% block title %}Analysis — Brew Log{% endblock %} + +{% block content %} +
+
+

Analysis

+

How consistently the brews ferment, and how long each one takes.

+
+ +
+ +{% if not rows %} +
+
+ +

No finished brews to analyse yet.

+

Once a run has a final reading it appears here.

+
+
+{% else %} + +
+
+
Average attenuation
+
+ {% if stats.avg_attenuation is not none %}{{ stats.avg_attenuation }}%{% else %}—{% endif %} +
+
of the original gravity fermented
+
+
+
Spread
+
+ {% if stats.consistency %}±{{ stats.consistency.stdev }}pp{% else %}—{% endif %} +
+
+ {% if stats.consistency %} + standard deviation of attenuation, lower is better + {% else %} + needs at least two brews + {% endif %} +
+
+
+
Average brew time
+
+ {% if stats.avg_days is not none %}{{ stats.avg_days }}d{% else %}—{% endif %} +
+
first reading to final reading
+
+
+
Time to 1.010
+
+ {% if stats.avg_days_to_1010 is not none %}{{ stats.avg_days_to_1010 }}d{% else %}—{% endif %} +
+
+ {% 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 %} +
+
+
+ +
+
+
+

Attenuation by brew

+ How much of the original gravity was fermented away +
+
+ +
+
+ +
+
+

Fermentation curves

+ Progress through fermentation, 0% to 100% +
+
+ +
+
+
+ +
+
+

Every finished brew

+ {{ stats.count }} brews +
+
+
+ + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + + {% endfor %} + +
RunNameOGFGABVAttenuationDaysTo 1.010Finished
{{ row.beer_run_id }}{{ row.name }} + {% if row.og_points is not none %} + {{ '%.3f' % (row.og_points / 1000) }} + {% else %}—{% endif %} + + {% if row.fg_points is not none %} + {{ '%.3f' % (row.fg_points / 1000) }} + {% else %}—{% endif %} + + {% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %} + + {% if row.attenuation is not none %} + {{ row.attenuation }}% + {% else %}—{% endif %} + {{ row.days if row.days is not none else '—' }}{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}{{ row.final_date or '—' }}
+
+
+
+ +{% if suspect %} +
+
+

Readings that need a second look

+ {{ suspect | length }} run{{ '' if suspect | length == 1 else 's' }} excluded from the averages +
+
+
+ + + + + + + + + + + {% for row in suspect %} + + + + + + + {% endfor %} + +
RunOGFGProblem
{{ row.beer_run_id }}{{ '%.3f' % (row.og_points / 1000) }}{{ '%.3f' % (row.fg_points / 1000) }}{{ row.problem }}
+
+
+
+{% endif %} + +{% if stats.consistency %} +
+
+

What the numbers say

+
+
+

+ Across {{ stats.count }} finished brews the attenuation averages + {{ stats.consistency.mean }}%, with a standard + deviation of {{ stats.consistency.stdev }} percentage + points 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 %} +

+ {% if stats.fastest and stats.slowest and stats.fastest != stats.slowest %} +

+ The quickest brew was {{ stats.fastest.name }} at + {{ stats.fastest.days }} days; the longest was + {{ stats.slowest.name }} at + {{ stats.slowest.days }} days. +

+ {% endif %} + {% if stats.avg_days_to_1010 %} +

+ 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. +

+ {% endif %} +
+
+{% endif %} + +{% endif %} +{% endblock %} diff --git a/src/flask/templates/base.html b/src/flask/templates/base.html index 3e78494..f998f91 100644 --- a/src/flask/templates/base.html +++ b/src/flask/templates/base.html @@ -23,6 +23,8 @@ href="{{ url_for('analysis.active') }}">Active Beers History + Analysis
diff --git a/src/flask/views/analysis.py b/src/flask/views/analysis.py index 0737c73..99d0cee 100644 --- a/src/flask/views/analysis.py +++ b/src/flask/views/analysis.py @@ -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. diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..f95fad9 --- /dev/null +++ b/tests/test_analysis.py @@ -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" + ) diff --git a/tests/test_app.py b/tests/test_app.py index b33f4ba..7f27e91 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -639,3 +639,65 @@ def test_month_range_covers_a_year_boundary(): "2025-11", "2025-12", "2026-01", "2026-02", ] 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