fix(charts): leave the suspect and active runs out of the bar charts #15
@@ -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; }
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p>
|
||||
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
|
||||
<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 }}
|
||||
|
||||
@@ -23,7 +23,13 @@
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average ABV</div>
|
||||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||||
<div class="stat__meta">across finished brews</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.abv_counted < stats.abv_total %}
|
||||
across {{ stats.abv_counted }} of {{ stats.abv_total }} finished brews
|
||||
{% else %}
|
||||
across finished brews
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Strongest</div>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user