fix(analysis): the review findings - rising gravity, undated readings, the band

A second, deeper review of PR #12 found five things the first review did
not.  All are fixed here.

The band was not the band
-------------------------
The fermentation curve's shaded range was drawn as ONE dataset with
`fill: "+1"`, which fills to the NEXT dataset - and the next dataset was
the median line, not the maximum.  So the band shaded min-to-median, the
upper half of the range was drawn as a hairline but never shaded, and the
tooltip said "range min to max" while the picture showed something else.
The band is now two datasets - the min curve filled down to a max curve -
with the max hidden from the legend.

Rising gravity
--------------
`attenuation_percent` rejected a final gravity below 1.000 and one
implying more than 100% attenuation, but a run whose gravity went UP
produced a NEGATIVE attenuation - which was then averaged into the
headline figure and plotted as a negative bar.  Verified against the live
code: OG 1010 -> FG 1020 gave -100.0%.  Now rejected, and reported by
`implausible_runs` with the reason "the gravity went up, which is usually
a mis-ordered pair of readings".

Undated readings
----------------
An undated reading sorted to the front of the run, so it became the
original gravity - which put the OG after the FG and produced a negative
attenuation.  The OG now comes from the earliest *dated* reading, and
`days_to_target` skips the readings it cannot place on the timeline rather
than giving up on the whole run.  Verified: readings 1050 (dated), 1005
(undated), 1010 (dated) gave None instead of 24 days, and an OG of 1005
instead of 1050.

Active runs on the curve
------------------------
The curve chart filtered on `curve.length > 1` only, so it plotted the
active runs as well - a partial fermentation on a chart meant to describe
how a completed one behaves.  It now excludes them, along with the
suspect runs.

Four fetches, one payload
-------------------------
`/api/runs` carries every reading of every run, and four charts each
fetched it independently - so the analysis page made two identical
round-trips of the whole collection.  There is now one shared fetch.

Also
----
- The "Readings that need a second look" table links to the run, so a
  suspect reading can be corrected in one click.
- `days_to_1015` and the `curves` variable were computed and never used.
  Removed.
- A reading below 1.000 now says what it is - "beer cannot be less dense
  than water, so this is almost certainly a typo (a 1.012 reading entered
  as 012?)".  The reading is still SAVED: rejecting it would lose the
  brewer's actual observation, and seeing it recorded is how they spot the
  mistake.

142 tests (up from 134).
This commit is contained in:
2026-11-09 15:55:56 +10:00
parent 21e7553ab4
commit 76a4d0805c
7 changed files with 276 additions and 32 deletions
+50 -9
View File
@@ -193,14 +193,32 @@ class Run:
"""Gravity points of every *usable* reading, in chronological order."""
return [r.sg_points for r in self.readings if r.sg_points is not None]
def dated_sg_values(self) -> list[int]:
"""Gravity points of the readings that have a date, chronologically.
An undated reading cannot be placed in the run's timeline, so it
must not be used as the original gravity: it sorts to the front
and would put the OG after the FG.
"""
dated = [r for r in self.readings
if r.sg_points is not None and r.date is not None]
return [r.sg_points for r in dated]
def original_gravity(self) -> int | None:
"""OG = the first usable reading of the run."""
values = self.sg_values()
"""OG = the first *dated* usable reading of the run."""
values = self.dated_sg_values()
return values[0] if values else None
def final_gravity(self) -> int | None:
"""FG = the last usable reading of the run (if the run is finished)."""
values = self.sg_values()
"""FG = the last *dated* usable reading of the run (if it is finished).
For a finished run the final reading is the one flagged as final,
which is the reading the brewer took when they racked it.
"""
final = self.final_reading()
if final is not None and final.sg_points is not None:
return final.sg_points
values = self.dated_sg_values()
return values[-1] if values else None
def max_sg(self) -> int | None:
@@ -491,6 +509,11 @@ def attenuation_percent(run: Run) -> float | None:
fg = run.final_gravity()
if og is None or fg is None or og <= 1000 or fg < 1000:
return None
if fg > og:
# The gravity went UP. That is not attenuation - it is either a
# mis-ordered pair of readings or a contaminated sample, and a
# negative attenuation is not a number a brewer can act on.
return None
attenuation = (og - fg) / (og - 1000) * 100
if attenuation > 100:
# Cannot ferment away more than the sugar that was there.
@@ -520,6 +543,17 @@ def implausible_runs(runs: Iterable[Run]) -> list[dict]:
"is probably wrong"
),
})
elif og is not None and fg is not None and fg > og:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"the final gravity {fg / 1000:.3f} is higher than the "
f"original {og / 1000:.3f} — the gravity went up, which "
"is usually a mis-ordered pair of readings"
),
})
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
and (og - fg) / (og - 1000) * 100 > 100:
problems.append({
@@ -537,12 +571,19 @@ def days_to_target(run: Run, target: int) -> int | None:
Used to answer "how long does this usually take?", which is the question
a brewer actually has when planning the next batch.
"""
first = run.first_reading()
if first is None or first.date is None:
# Without a start date there is no elapsed time to measure.
return None
# Skip the readings that cannot be placed on the timeline (no date)
# rather than giving up on the whole run because of one of them.
for reading in run.readings:
if reading.sg_points is not None and reading.sg_points <= target:
first = run.first_reading()
if first is None or first.date is None or reading.date is None:
return None
return days_between(first.date, reading.date)
if reading.sg_points is None or reading.sg_points > target:
continue
if reading.date is None:
continue
return days_between(first.date, reading.date)
return None