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 %} +
How consistently the brews ferment, and how long each one takes.
+No finished brews to analyse yet.
+Once a run has a final reading it appears here.
+| Run | +Name | +OG | +FG | +ABV | +Attenuation | +Days | +To 1.010 | +Finished | +
|---|---|---|---|---|---|---|---|---|
| {{ 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 '—' }} | +
| Run | +OG | +FG | +Problem | +
|---|---|---|---|
| {{ row.beer_run_id }} | +{{ '%.3f' % (row.og_points / 1000) }} | +{{ '%.3f' % (row.fg_points / 1000) }} | +{{ row.problem }} | +
+ 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 %} +