fix(charts): leave the suspect and active runs out of the bar charts (#15)
Build and Push Image / Build and push image (push) Successful in 3m19s
Build and Push Image / Build and push image (push) Successful in 3m19s
fix(charts): leave the suspect and active runs out of the bar charts (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -184,8 +184,16 @@
|
|||||||
// Only finished brews have a measured ABV. An unfinished run
|
// Only finished brews have a measured ABV. An unfinished run
|
||||||
// has a projection, not a measurement, so it is not plotted
|
// has a projection, not a measurement, so it is not plotted
|
||||||
// here.
|
// 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) {
|
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; }
|
if (!completed.length) { return; }
|
||||||
|
|
||||||
@@ -305,8 +313,14 @@
|
|||||||
// A run with an impossible reading has no attenuation, and
|
// A run with an impossible reading has no attenuation, and
|
||||||
// is flagged as suspect. Both are excluded, so one bad
|
// is flagged as suspect. Both are excluded, so one bad
|
||||||
// reading cannot become a bar in the chart.
|
// 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) {
|
var done = runs.filter(function (r) {
|
||||||
return !r.suspect &&
|
return !r.suspect && !r.active &&
|
||||||
r.attenuation !== null && r.attenuation !== undefined;
|
r.attenuation !== null && r.attenuation !== undefined;
|
||||||
});
|
});
|
||||||
if (!done.length) { return; }
|
if (!done.length) { return; }
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<p>
|
<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
|
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
|
||||||
deviation of <strong>{{ stats.consistency.stdev }} percentage
|
deviation of <strong>{{ stats.consistency.stdev }} percentage
|
||||||
points</strong> and a total range of {{ stats.consistency.spread }}
|
points</strong> and a total range of {{ stats.consistency.spread }}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Average ABV</div>
|
<div class="stat__label">Average ABV</div>
|
||||||
<div class="stat__value">{{ '%.2f' % stats.avg_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>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Strongest</div>
|
<div class="stat__label">Strongest</div>
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ def deep():
|
|||||||
suspect = implausible_runs(finished)
|
suspect = implausible_runs(finished)
|
||||||
suspect_ids = {s["beer_run_id"] for s in suspect}
|
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 = []
|
rows = []
|
||||||
for run in finished:
|
for run in finished:
|
||||||
meta = metadata.get(run.beer_run_id, {})
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
@@ -111,6 +120,10 @@ def deep():
|
|||||||
"suspect_count": len(suspect),
|
"suspect_count": len(suspect),
|
||||||
# How many runs the figures are actually based on.
|
# How many runs the figures are actually based on.
|
||||||
"trusted_count": len(good),
|
"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)
|
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
||||||
if attenuations else None,
|
if attenuations else None,
|
||||||
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
||||||
@@ -278,5 +291,9 @@ def history():
|
|||||||
"avg_abv": avg_abv,
|
"avg_abv": avg_abv,
|
||||||
"best": best,
|
"best": best,
|
||||||
"worst": worst,
|
"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
|
# The runs whose readings are physically impossible. They are flagged
|
||||||
# rather than dropped: the charts use the flag to leave them out of the
|
# 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.
|
# 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)}
|
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
||||||
|
|
||||||
payload = []
|
payload = []
|
||||||
@@ -228,6 +232,7 @@ def api_run(run_id: str):
|
|||||||
# The same flag the list endpoint carries, so a consumer of the detail
|
# The same flag the list endpoint carries, so a consumer of the detail
|
||||||
# endpoint can tell an impossible run from a good one.
|
# endpoint can tell an impossible run from a good one.
|
||||||
summary["suspect"] = bool(implausible_runs([run]))
|
summary["suspect"] = bool(implausible_runs([run]))
|
||||||
|
summary["active"] = run.is_active()
|
||||||
return jsonify(summary)
|
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"
|
"the run payload should be fetched once and shared"
|
||||||
)
|
)
|
||||||
assert "function fetchRuns()" in charts_js
|
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