fix(analysis): exclude suspect runs from every average, not just attenuation
Review finding, and it was right. The PR claimed that runs with impossible readings were "excluded from the averages". That was only true of the attenuation average. Run 12's bad final gravity was still poisoning three other statistics: - **Brew time** - its 56 days were folded into the average, making a typical brew look like 13.7 days when it is 10.9. - **Time to 1.010** - its 56 days did the same to that figure (8.4d). - **The history page's ABV** - an 18.64% ABV, which made run 12 the "strongest" beer in the history and pulled the average up. - **The fermentation curve** - its curve plunges from 1.042 to 0.900, which dragged the median line down and stretched the min-max envelope to cover a gravity below water. One bad reading was reaching four different statistics. What changed ------------ - The suspect runs are computed once and excluded from every aggregate: the attenuation, brew-time and time-to-1.010 averages, the fastest and slowest brews, the history page's ABV average, and its strongest and lightest. - `/api/runs` now carries a `suspect` flag, and the charts skip the flagged runs - the attenuation chart and the fermentation curve. - The suspect run stays in the table, with a "not counted" chip and a tinted row, so it can be corrected. It is excluded from the figures, not hidden. The card header says "16 brews (15 counted)" and the prose says how many brews the figures are based on. Tests ----- 133 (up from 127). New coverage: the suspect run is excluded from the brew time and time-to-1.010 averages, it is still listed for review, it is flagged in the API, it is not the strongest brew in the history, and the charts filter it out. The `logged_in` fixture moved to conftest.py, where both test modules can use it.
This commit is contained in:
@@ -498,6 +498,13 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||
.chip--warn { background: var(--warn-dim); color: var(--warn); }
|
||||
.chip--danger { background: var(--danger-dim); color: var(--danger); }
|
||||
.chip--good { background: var(--success-dim); color: var(--success); }
|
||||
/* A run whose readings are impossible: shown in the table so it can be
|
||||
corrected, but marked as not counted in the averages. */
|
||||
.data tbody tr.is-suspect td {
|
||||
color: var(--text-muted);
|
||||
background: rgba(232, 163, 61, .06);
|
||||
}
|
||||
|
||||
.chip--tap {
|
||||
background: var(--bg-raised-2);
|
||||
border: 1px solid var(--amber-deep);
|
||||
|
||||
@@ -305,8 +305,12 @@
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
// 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.
|
||||
var done = runs.filter(function (r) {
|
||||
return r.attenuation !== null && r.attenuation !== undefined;
|
||||
return !r.suspect &&
|
||||
r.attenuation !== null && r.attenuation !== undefined;
|
||||
});
|
||||
if (!done.length) { return; }
|
||||
|
||||
@@ -383,8 +387,11 @@
|
||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (runs) {
|
||||
// A run with an impossible reading has a broken curve - it
|
||||
// plunges to a gravity below water - which would drag the
|
||||
// median line down and stretch the envelope to cover it.
|
||||
var withCurve = runs.filter(function (r) {
|
||||
return r.curve && r.curve.length > 1;
|
||||
return !r.suspect && r.curve && r.curve.length > 1;
|
||||
});
|
||||
if (!withCurve.length) { return; }
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every finished brew</h2>
|
||||
<span class="card__hint">{{ stats.count }} brews</span>
|
||||
<span class="card__hint">{{ stats.count }} brews{% if stats.suspect_count %} ({{ stats.trusted_count }} counted){% endif %}</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
@@ -112,9 +112,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
|
||||
<td class="num">
|
||||
{% if row.og_points is not none %}
|
||||
{{ '%.3f' % (row.og_points / 1000) }}
|
||||
@@ -184,7 +184,7 @@
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p>
|
||||
Across {{ stats.count }} finished brews the attenuation averages
|
||||
Across {{ stats.trusted_count }} 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 }}
|
||||
|
||||
+32
-11
@@ -48,10 +48,12 @@ def deep():
|
||||
finished = [r for r in runs if not r.is_active()]
|
||||
|
||||
# Runs whose gravity readings are physically impossible are excluded from
|
||||
# the averages and listed separately, rather than being silently folded
|
||||
# in (a final gravity below 1.000 would otherwise show as a 338%
|
||||
# attenuation).
|
||||
# EVERY aggregate and listed separately, rather than being silently
|
||||
# folded in. A final gravity below 1.000 would otherwise show as a
|
||||
# 338% attenuation AND a 56-day brew AND the strongest beer in the
|
||||
# history - one bad reading poisoning three different statistics.
|
||||
suspect = implausible_runs(finished)
|
||||
suspect_ids = {s["beer_run_id"] for s in suspect}
|
||||
|
||||
rows = []
|
||||
for run in finished:
|
||||
@@ -71,14 +73,20 @@ def deep():
|
||||
"days_to_1015": days_to_target(run, 1015),
|
||||
"readings": summary["reading_count"],
|
||||
"curve": summary["curve"],
|
||||
# A suspect run is shown in the table so it can be corrected,
|
||||
# but it contributes to none of the averages.
|
||||
"suspect": run.beer_run_id in suspect_ids,
|
||||
})
|
||||
|
||||
# The rows with a usable curve, for the overlay chart.
|
||||
curves = [r for r in rows if r["curve"]]
|
||||
|
||||
attenuations = [r["attenuation"] for r in rows if r["attenuation"] is not None]
|
||||
durations = [r["days"] for r in rows if r["days"] is not None]
|
||||
to_1010 = [r["days_to_1010"] for r in rows if r["days_to_1010"] is not None]
|
||||
# Only the trustworthy runs feed the statistics.
|
||||
good = [r for r in rows if not r["suspect"]]
|
||||
|
||||
attenuations = [r["attenuation"] for r in good if r["attenuation"] is not None]
|
||||
durations = [r["days"] for r in good if r["days"] is not None]
|
||||
to_1010 = [r["days_to_1010"] for r in good if r["days_to_1010"] is not None]
|
||||
|
||||
# --- consistency ------------------------------------------------------- #
|
||||
# How much the attenuation varies. A tight spread means the process is
|
||||
@@ -93,9 +101,9 @@ def deep():
|
||||
"spread": round(max(attenuations) - min(attenuations), 1),
|
||||
}
|
||||
|
||||
fastest = min((r for r in rows if r["days"] is not None),
|
||||
fastest = min((r for r in good if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
slowest = max((r for r in rows if r["days"] is not None),
|
||||
slowest = max((r for r in good if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
|
||||
return render_template(
|
||||
@@ -105,6 +113,9 @@ def deep():
|
||||
suspect=suspect,
|
||||
stats={
|
||||
"count": len(rows),
|
||||
"suspect_count": len(suspect),
|
||||
# How many runs the figures are actually based on.
|
||||
"trusted_count": len(good),
|
||||
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
||||
if attenuations else None,
|
||||
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
||||
@@ -198,6 +209,8 @@ def history():
|
||||
|
||||
summaries = [_decorate(run, metadata) for run in runs]
|
||||
completed = [s for s in summaries if not s["active"]]
|
||||
# The Run objects for the finished brews, for the plausibility check.
|
||||
completed_runs = [run for run in runs if not run.is_active()]
|
||||
|
||||
# --- ABV comparison: standard formula vs the legacy one ---------------- #
|
||||
abv_rows = []
|
||||
@@ -244,9 +257,17 @@ def history():
|
||||
"comment": reading["comment"],
|
||||
})
|
||||
|
||||
avg_abv = round(sum(r["abv"] for r in abv_rows) / len(abv_rows), 2) if abv_rows else 0
|
||||
best = max(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
|
||||
worst = min(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
|
||||
# A run with an impossible final gravity produces an impossible ABV
|
||||
# (run 12 reads 18.64%), which would make it the "strongest" beer in
|
||||
# the history and drag the average up. Those runs are excluded from
|
||||
# the ABV figures here and listed on the analysis page.
|
||||
history_suspect = {s["beer_run_id"] for s in implausible_runs(completed_runs)}
|
||||
good_abv_rows = [r for r in abv_rows if r["beer_run_id"] not in history_suspect]
|
||||
|
||||
avg_abv = round(sum(r["abv"] for r in good_abv_rows) / len(good_abv_rows), 2) \
|
||||
if good_abv_rows else 0
|
||||
best = max(good_abv_rows, key=lambda r: r["abv"]) if good_abv_rows else None
|
||||
worst = min(good_abv_rows, key=lambda r: r["abv"]) if good_abv_rows else None
|
||||
|
||||
return render_template(
|
||||
"history.html",
|
||||
|
||||
@@ -9,7 +9,7 @@ from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||
SubmitField, TextAreaField)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
from brewing import is_plausible_sg, run_summary
|
||||
from brewing import implausible_runs, is_plausible_sg, run_summary
|
||||
|
||||
bp = Blueprint("brews", __name__)
|
||||
|
||||
@@ -178,6 +178,11 @@ def api_runs():
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
# 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.
|
||||
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
||||
|
||||
payload = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
@@ -185,6 +190,7 @@ def api_runs():
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summary["suspect"] = run.beer_run_id in suspect_ids
|
||||
payload.append(summary)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
Reference in New Issue
Block a user