From 794df0c12ee0250d3cb0a8b23479b2e7bc58359e Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 16:46:30 +1000 Subject: [PATCH] fix(charts): leave the suspect and active runs out of the bar charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second review of PR #12 (after the suspect-run fix) found that the two BAR charts were still plotting runs they should not have been, while the curve chart and the tiles had been corrected. The page was contradicting itself. The ABV chart plotted the suspect run -------------------------------------- Run 12's impossible reading gives it an ABV of 18.64%. The chart's y axis went to 18.64, so the fifteen real bars were squashed into the bottom ~45% of the plot - while the "Strongest 8.53%" tile on the same page showed the correct figure. The chart now leaves the suspect runs out. The attenuation chart plotted the active runs ---------------------------------------------- An active run's attenuation is measured against its CURRENT gravity, so a brew that has just started shows a 0% bar. That is the absence of a result, not a result, and it sat directly above the table of finished brews. The chart now leaves the active runs out. Verified against the live data: ABV chart bars: bars=15 max=8.53 (was 16 bars, max 18.64) attenuation chart bars: bars=15 min=76.2 (was 17 bars, min 0) A suspect active run no longer vanishes --------------------------------------- The API flags suspect runs over EVERY run, so the charts leave them out. But the analysis page only looked at the finished ones, so an active run with a bad reading would be dropped from the charts and never listed in "Readings that need a second look" - it would disappear with no explanation anywhere. The analysis page now lists suspect active runs too. The figures say what they are based on -------------------------------------- - The analysis prose counted the non-suspect runs, not the ones that actually contributed a figure to the mean. It now counts the latter. - The history page's "Average ABV — across finished brews" was averaging 15 of 16 without saying so, while the analysis page said "16 brews (15 counted)". It now says "across 15 of 16 finished brews". - `/api/runs/` carries the `suspect` and `active` flags that `/api/runs` does. 148 tests (up from 142). --- src/flask/static/js/charts.js | 18 ++++- src/flask/templates/analysis.html | 2 +- src/flask/templates/history.html | 8 +- src/flask/views/analysis.py | 17 +++++ src/flask/views/brews.py | 5 ++ tests/test_analysis.py | 118 ++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/flask/static/js/charts.js b/src/flask/static/js/charts.js index 772c451..92f1cc6 100644 --- a/src/flask/static/js/charts.js +++ b/src/flask/static/js/charts.js @@ -184,8 +184,16 @@ // Only finished brews have a measured ABV. An unfinished run // has a projection, not a measurement, so it is not plotted // here. + // + // A suspect run is left out too. Its ABV comes from an + // impossible reading (run 12 reads 18.64%), and plotting it + // stretched the y axis so that every real bar was squashed + // into the bottom of the plot - while the "Strongest" tile + // on the same page correctly showed 8.53%. The chart and + // the tile contradicted each other. var completed = runs.filter(function (r) { - return r.abv !== null && r.abv !== undefined; + return !r.suspect && + r.abv !== null && r.abv !== undefined; }); if (!completed.length) { return; } @@ -305,8 +313,14 @@ // A run with an impossible reading has no attenuation, and // is flagged as suspect. Both are excluded, so one bad // reading cannot become a bar in the chart. + // + // Active runs are excluded as well. Their attenuation is + // measured against their CURRENT gravity, so a brew that + // has just started shows a 0% bar - which is not a result, + // it is the absence of one. The chart is about finished + // brews, and it sits above the table of finished brews. var done = runs.filter(function (r) { - return !r.suspect && + return !r.suspect && !r.active && r.attenuation !== null && r.attenuation !== undefined; }); if (!done.length) { return; } diff --git a/src/flask/templates/analysis.html b/src/flask/templates/analysis.html index 747b27f..7476bdd 100644 --- a/src/flask/templates/analysis.html +++ b/src/flask/templates/analysis.html @@ -186,7 +186,7 @@

- Across {% if stats.suspect_count %}{{ stats.trusted_count }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages + Across {% if stats.suspect_count %}{{ stats.counted_attenuation }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} 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 }} diff --git a/src/flask/templates/history.html b/src/flask/templates/history.html index 3e493dd..a6c1530 100644 --- a/src/flask/templates/history.html +++ b/src/flask/templates/history.html @@ -23,7 +23,13 @@

Average ABV
{{ '%.2f' % stats.avg_abv }}%
-
across finished brews
+
+ {% if stats.abv_counted < stats.abv_total %} + across {{ stats.abv_counted }} of {{ stats.abv_total }} finished brews + {% else %} + across finished brews + {% endif %} +
Strongest
diff --git a/src/flask/views/analysis.py b/src/flask/views/analysis.py index 2b1011e..078e161 100644 --- a/src/flask/views/analysis.py +++ b/src/flask/views/analysis.py @@ -55,6 +55,15 @@ def deep(): suspect = implausible_runs(finished) suspect_ids = {s["beer_run_id"] for s in suspect} + # A run that is still fermenting can also have an impossible reading, + # and the API flags those too - so the charts leave them out. If they + # were not listed here they would disappear from the analysis page + # entirely, with no explanation anywhere. + active_suspect = implausible_runs([r for r in runs if r.is_active()]) + if active_suspect: + suspect = suspect + active_suspect + suspect_ids |= {s["beer_run_id"] for s in active_suspect} + rows = [] for run in finished: meta = metadata.get(run.beer_run_id, {}) @@ -111,6 +120,10 @@ def deep(): "suspect_count": len(suspect), # How many runs the figures are actually based on. "trusted_count": len(good), + # How many of those actually contributed a figure to the + # average - a run with no original gravity has no attenuation, + # so it is counted above but not in the mean. + "counted_attenuation": len(attenuations), "avg_attenuation": round(sum(attenuations) / len(attenuations), 1) if attenuations else None, "avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None, @@ -278,5 +291,9 @@ def history(): "avg_abv": avg_abv, "best": best, "worst": worst, + # How many of the finished brews the ABV figures are based + # on, so the tile can say so. + "abv_counted": len(good_abv_rows), + "abv_total": len(completed), }, ) diff --git a/src/flask/views/brews.py b/src/flask/views/brews.py index e1c4d30..fdd6238 100644 --- a/src/flask/views/brews.py +++ b/src/flask/views/brews.py @@ -198,6 +198,10 @@ def api_runs(): # The runs whose readings are physically impossible. They are flagged # rather than dropped: the charts use the flag to leave them out of the # averages and the curve envelope, but a caller can still see them. + # + # The flag is computed over EVERY run, not just the finished ones, so + # it agrees with what the charts do. An active run with a bad reading + # is still a run with a bad reading. suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)} payload = [] @@ -228,6 +232,7 @@ def api_run(run_id: str): # The same flag the list endpoint carries, so a consumer of the detail # endpoint can tell an impossible run from a good one. summary["suspect"] = bool(implausible_runs([run])) + summary["active"] = run.is_active() return jsonify(summary) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index ebc712c..97c909d 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -508,3 +508,121 @@ def test_the_charts_fetch_the_run_payload_once(): "the run payload should be fetched once and shared" ) assert "function fetchRuns()" in charts_js + +# ------------------------------------------------- the chart exclusions # + +def test_the_abv_chart_leaves_out_the_suspect_runs(): + """A suspect run's ABV comes from an impossible reading. + + Run 12 reads 18.64%, and plotting it stretched the y axis so every + real bar was squashed into the bottom of the plot - while the + "Strongest" tile on the same page correctly showed 8.53%. The chart + and the tile contradicted each other. + """ + charts_js = ( + Path(__file__).resolve().parents[1] + / "src" / "flask" / "static" / "js" / "charts.js" + ).read_text() + + abv_chart = charts_js.split("function drawAbvChart")[1] + abv_chart = abv_chart.split("function drawMonthsChart")[0] + assert "!r.suspect" in abv_chart, ( + "the ABV chart must leave the suspect runs out" + ) + + +def test_the_attenuation_chart_leaves_out_the_active_runs(): + """An active run's attenuation is measured against its CURRENT gravity. + + A brew that has just started shows a 0% bar - which is the absence of + a result, not a result. The chart sits above the table of finished + brews. + """ + charts_js = ( + Path(__file__).resolve().parents[1] + / "src" / "flask" / "static" / "js" / "charts.js" + ).read_text() + + att_chart = charts_js.split("function drawAttenuationChart")[1] + att_chart = att_chart.split("function drawCurveChart")[0] + assert "!r.active" in att_chart, ( + "the attenuation chart must leave the active runs out" + ) + + +def test_a_suspect_active_run_is_still_listed_for_review(logged_in, repo): + """A bad reading in an active run must not make the run vanish. + + The API flags suspect runs over EVERY run, so the charts leave them + out. If the analysis page only looked at the finished ones, an + active run with a bad reading would disappear with no explanation + anywhere. + """ + seed_readings(repo, [ + ("1", "Finished ale", "1050", "2026-01-01", False, ""), + ("1", "Finished ale", "1010", "2026-01-10", True, ""), + # An ACTIVE run whose latest reading is below water. + ("20", "Active brew", "1042", "2026-02-01", False, ""), + ("20", "Active brew", "0900", "2026-02-10", False, "typo"), + ]) + + response = logged_in.get("/analysis") + body = response.get_data(as_text=True) + + assert "Readings that need a second look" in body + assert "0.900" in body + + +def test_the_history_page_says_how_many_brews_the_abv_figure_is_from(logged_in, repo): + """The tile must not quietly average fewer brews than it claims.""" + seed_readings(repo, [ + ("1", "Good ale", "1050", "2026-01-01", False, ""), + ("1", "Good ale", "1010", "2026-01-10", True, ""), + ("12", "Bad brew", "1042", "2026-01-01", False, ""), + ("12", "Bad brew", "0900", "2026-02-26", True, "typo"), + ]) + + response = logged_in.get("/history") + body = response.get_data(as_text=True) + + assert "across 1 of 2 finished brews" in body + + +def test_the_analysis_prose_counts_the_brews_that_contributed(logged_in, repo): + """The prose must not claim more brews than actually contributed. + + The prose is rendered from the attenuation figures, so it counts the + brews that produced one - not the brews that were merely counted. + """ + seed_readings(repo, [ + ("1", "Good ale", "1050", "2026-01-01", False, ""), + ("1", "Good ale", "1010", "2026-01-10", True, ""), + ("2", "Good pale", "1042", "2026-02-01", False, ""), + ("2", "Good pale", "1012", "2026-02-10", True, ""), + ("12", "Bad brew", "1042", "2026-01-01", False, ""), + ("12", "Bad brew", "0900", "2026-02-26", True, "typo"), + ]) + + response = logged_in.get("/analysis") + body = " ".join(response.get_data(as_text=True).split()) + + # Three finished brews, two of which contributed an attenuation. + assert "Across 2 of 3 finished brews" in body + + +def test_the_run_detail_api_carries_the_same_flags(logged_in, repo): + """A consumer of the detail endpoint can tell an impossible run from a + good one, and an active run from a finished one.""" + seed_readings(repo, [ + ("1", "Good ale", "1050", "2026-01-01", False, ""), + ("1", "Good ale", "1010", "2026-01-10", True, ""), + ("20", "Active brew", "1042", "2026-02-01", False, ""), + ]) + + finished = logged_in.get("/api/runs/1").get_json() + assert finished["suspect"] is False + assert finished["active"] is False + + active = logged_in.get("/api/runs/20").get_json() + assert active["suspect"] is False + assert active["active"] is True -- 2.39.5