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