Compare commits

...
5 Commits
Author SHA1 Message Date
hermes 21e7553ab4 feat(analysis): attenuation, fermentation curves and brew timings (#12)
Build and Push Image / Build and push image (push) Successful in 8m10s
feat(analysis): attenuation, fermentation curves and brew timings (#12)
2026-11-09 14:18:44 +10:00
hermes 6fec78e9e4 fix(analysis): address the two review nits
The re-review returned APPROVED with two low-severity notes.

- The prose said "Across 15 finished brews", which reads as though there
  are only 15 brews.  It now says "Across 15 of 16 finished brews", so the
  exclusion is visible in the sentence itself and not only in the card
  header.

- `/api/runs/<run_id>` did not carry the `suspect` flag that
  `/api/runs` does, so a consumer of the detail endpoint could not tell an
  impossible run from a good one.  It does now, with a test.

134 tests.
2026-11-09 14:18:29 +10:00
hermes bbd9fe17e5 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.
2026-11-09 14:03:07 +10:00
hermes 4d1e070d74 docs: document the analysis page and the CI deploy flow
The RUNBOOK still said the workflow did not deploy to the cluster. It does
now - it builds, pushes, applies the manifests, rolls the pods and verifies
the app from inside the pod. The manual deploy path is documented alongside
it, along with the dry-run step that catches the schema errors kubectl apply
silently swallows.
2026-11-09 13:12:51 +10:00
hermes e1a9a7384f feat(analysis): attenuation, fermentation curves and brew timings
Adds the analysis page the old app never had: how consistently the brews
ferment, and how long each one takes.

What it shows
-------------
- **Attenuation by brew** - what fraction of the original gravity was
  fermented away, for every finished brew.
- **Fermentation curves** - the typical shape of a fermentation, drawn as a
  median curve with the min-max envelope behind it. Plotting every brew
  individually gave 16 near-identical lines and an unreadable legend.
- **Every finished brew** - OG, FG, ABV, attenuation, days in the fermenter,
  days to reach 1.010, and the finish date.
- **Readings that need a second look** - readings outside the plausible
  hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
  average time to 1.010.

Data quality
------------
One live run (run 12) has a final gravity of 900, which worked out as 338%
attenuation and dragged the average to 103.2%. That is not a real result,
so:

- Attenuation is rejected when the final gravity is not physically possible
  for the original gravity.
- Runs that fail that check are EXCLUDED from the averages rather than
  silently distorting them, and are listed on the page as needing review.
- The average now reads 87.5% +/- 7.4 percentage points across 16 brews.

Chart bugs found while building this
------------------------------------
- **The gravity charts had their y axis reversed.** With `reverse: true`
  the high reading sits at the BOTTOM, so the fermentation curve climbed
  left to right - it read as gravity *rising* as the beer fermented. A
  brewer reads a fermentation as a descending line. The point-scale run
  chart on the run detail page had the same bug. Both are fixed.
- **The curve chart's x axis was treated as categorical.** The x values are
  numeric (0-100), and Chart.js defaults a line chart's x axis to
  category, which collapsed every point onto index 0 and rendered the
  curve as a sliver at the origin. The axis is now explicitly linear.
- **The curve was interpolated by exact key lookup**, so it only worked when
  every brew happened to be read at the same progress points. It now
  interpolates between a brew's actual readings, and the curve carries 21
  points across the full range.
- **Long beer names were clipped** at the left edge of the attenuation
  chart ("...x crying out loud"). Names are trimmed to 18 characters with
  an ellipsis; the tooltip and the table carry the full name.

Tests
-----
127 (up from 105). The two chart regressions are covered by reading
`charts.js` and asserting the gravity axis is not reversed and that long
names are trimmed - these are exactly the kind of change that silently
reverts, and they are invisible to a screenshot test.
2026-11-09 13:11:23 +10:00
12 changed files with 1296 additions and 22 deletions
+22 -1
View File
@@ -42,6 +42,7 @@ The tests use `mongomock`, so they need no database and no network.
| `/brews` | The brew process: record a reading, review every run |
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
| `/analysis` | How consistently the brews ferment — attenuation, fermentation curves, brew timings |
### `/active` — the active beers page
@@ -59,6 +60,27 @@ A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number.
Record the OG on the run (or take a second reading) and it appears.
### `/analysis` — how the brews ferment
The page that turns the history into something you can brew better from:
- **Attenuation by brew** — what fraction of the original gravity was
fermented away, for every finished brew.
- **Fermentation curves** — the typical shape of a fermentation, drawn as a
median curve with the min–max envelope behind it. Plotting every brew
individually gives sixteen near-identical lines and an unreadable legend.
- **Every finished brew** — OG, FG, ABV, attenuation, days in the fermenter,
days to reach 1.010, and the finish date.
- **Readings that need a second look** — readings outside the plausible
hydrometer range.
- Headline figures: average attenuation and its spread, average brew time,
average time to 1.010.
A reading that is physically impossible — a final gravity below the original
gravity by more than the wort itself could have — is excluded from the
averages rather than silently distorting them, and the run is listed on the
page so it can be corrected.
### Run metadata
Some details cannot be derived from the readings, so they are stored per run in
@@ -94,7 +116,6 @@ src/flask/
views/ blueprints: auth, brews, analysis
mongo/repository.py Mongo access for the beer_data and beer_runs collections
templates/ Jinja templates
static/ CSS, JS, fonts, logo (all served locally — no CDN)
static/ CSS, JS, fonts, logo (all served locally - no CDN)
tests/ pytest suite
scripts/ operational scripts (the run-metadata backfill)
+41 -3
View File
@@ -69,15 +69,53 @@ env:
## Deploying
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the
test suite and then builds and pushes the image. **The workflow does not
currently deploy to the cluster** — apply the manifests yourself:
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which builds
the image, pushes it, **deploys it to the cluster and verifies it**. There is
no manual step.
The workflow is a single job, in this order:
1. **Checkout**
2. **Create Kubeconfig** — writes `secrets.KUBEC_CONFIG_BUILDX_NEW_2` to
`$HOME/.kube/config`. This is FIRST because the buildx `kubernetes` driver
needs it to create its builder pod in the cluster.
3. **Set up Docker Buildx** — `driver: kubernetes`, `namespace=gitea-runner`.
4. **Login to the registry** — `secrets.REG_PASSWORD`.
5. **Build and Push** — `linux/amd64` only. A multi-arch build runs qemu,
which crashes the buildkit pod on this NUC (exit 255, `SandboxChanged`)
and the build then hangs for many minutes.
6. **Deploy** — installs kubectl, applies `kube/*.yaml`, creates the
image-pull secret idempotently, then `rollout restart` + `rollout status`.
The rollout restart is required: the image tag in the manifest is
`:latest`, so `kubectl apply` alone does not roll the pods.
7. **Verify the deployed app** — exercises `/healthz` and `/readyz` from
inside the pod, using the pod's own python (there is no `curl` in the
slim image).
> The deploy step does **not** delete the namespace. That would destroy the
> hand-provisioned `beer-db` and `beer-app` Secrets.
### Deploying by hand
If you need to deploy without going through CI — for example to test a
manifest change — do the same thing the workflow does:
```bash
kubectl apply -f kube/beer_namespace.yaml
kubectl apply -f kube/beer-deployment.yaml
kubectl apply -f kube/beer-service.yaml
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
```
Validate the manifests before applying them. `kubectl apply` **silently
rejects** a manifest with a schema error, so a broken manifest leaves the
old Deployment in place while the job still reports success:
```bash
kubectl apply --dry-run=client -f kube/
```
### Verify the deploy actually took
CI green and pods `Ready` do **not** prove the new code is running. Check the
+117
View File
@@ -431,6 +431,121 @@ def _fmt_point_scale(points: int | None) -> str:
return f"{points % 1000}"
def fermentation_curve(run: Run) -> list[dict]:
"""The run's gravity readings shaped for plotting.
Each point carries the reading date, the gravity, and how far through
the fermentation it was - the fraction of the total gravity drop that
had happened by that reading. That makes two runs with different
gravities directly comparable, which is the point of the chart.
"""
values = run.sg_values()
if len(values) < 2:
return []
og = values[0]
total_drop = og - values[-1]
if total_drop <= 0:
# No drop to measure progress against.
return []
points = []
for reading in run.readings:
if reading.sg_points is None:
continue
drop = og - reading.sg_points
points.append({
"date": reading.date.isoformat() if reading.date else None,
"sg": reading.sg_points,
"sg_decimal": _fmt_decimal(reading.sg_points),
"point_scale": _fmt_point_scale(reading.sg_points),
# 0.0 at the start, 1.0 at the final reading.
"progress": round(max(0.0, min(1.0, drop / total_drop)), 4),
"final": reading.final_reading,
"comment": reading.comment,
})
return points
def attenuation_percent(run: Run) -> float | None:
"""The apparent attenuation: how much of the original gravity was
fermented away.
This is the number a brewer actually compares between recipes, and it is
more informative than ABV alone because it normalises for how strong the
starting wort was.
Returns ``None`` when the figure would be meaningless:
- The original gravity was not above 1.000, so there is nothing to
attenuate.
- The final gravity is below 1.000. Beer cannot be less dense than
water, so a reading below 1.000 means the reading is wrong (a
mis-entry, or a hydrometer read at the wrong temperature). Run 12 in
the live data has a final gravity of 900, which would otherwise show
as a nonsense 338% attenuation. The run is reported separately by
:func:`implausible_runs` rather than being quietly folded into the
averages.
"""
og = run.original_gravity()
fg = run.final_gravity()
if og is None or fg is None or og <= 1000 or fg < 1000:
return None
attenuation = (og - fg) / (og - 1000) * 100
if attenuation > 100:
# Cannot ferment away more than the sugar that was there.
return None
return round(attenuation, 1)
def implausible_runs(runs: Iterable[Run]) -> list[dict]:
"""The runs whose gravity readings are physically impossible.
Beer cannot be less dense than water, so a final gravity below 1.000
means a reading is wrong. These runs are excluded from the averages and
listed on the analysis page instead of being silently included.
"""
problems = []
for run in runs:
og = run.original_gravity()
fg = run.final_gravity()
if fg is not None and fg < 1000:
problems.append({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": (
f"final gravity {fg / 1000:.3f} is below 1.000 — "
"beer cannot be less dense than water, so this reading "
"is probably wrong"
),
})
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({
"beer_run_id": run.beer_run_id,
"og_points": og,
"fg_points": fg,
"problem": "the gravity drop implies more than 100% attenuation",
})
return problems
def days_to_target(run: Run, target: int) -> int | None:
"""How many days the run took to reach *target* gravity.
Used to answer "how long does this usually take?", which is the question
a brewer actually has when planning the next batch.
"""
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)
return None
def run_summary(run: Run, today: date | None = None,
og_override: int | None = None) -> dict:
"""A flat dict describing a run, ready for a template or a JSON response."""
@@ -466,6 +581,8 @@ def run_summary(run: Run, today: date | None = None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"last_comment": run.comments()[-1] if run.comments() else "",
"attenuation": attenuation_percent(run),
"curve": fermentation_curve(run),
"outliers": [r.sg_points for r in run.outlier_readings()],
"readings": [
{
+7
View File
@@ -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);
+299 -2
View File
@@ -152,8 +152,18 @@
}
},
scales: {
x: {
// The dates are strings, so this axis stays
// categorical - but it must be declared,
// because Chart.js would otherwise infer
// something else from the {x, y} points.
type: "category",
ticks: { maxRotation: 0, autoSkipPadding: 12 }
},
y: {
reverse: true,
// NOT reversed - see the fermentation curve.
// Gravity falls as the beer ferments, so the
// line must descend left to right.
title: { display: true, text: "Specific gravity (points)" }
}
}
@@ -206,7 +216,19 @@
},
scales: {
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
x: { ticks: { maxRotation: 45, minRotation: 0 } }
x: {
// The names are long and the chart is narrow,
// so the labels are trimmed above and
// rotated. autoSkip keeps them from
// overlapping; the tooltip carries the
// full name for every bar.
ticks: {
maxRotation: 45,
minRotation: 45,
autoSkip: true,
autoSkipPadding: 8
}
}
}
})
});
@@ -274,11 +296,286 @@
.catch(function (err) { console.warn(err); });
}
/* ------------------------------------------------ attenuation by brew */
function drawAttenuationChart() {
var canvas = document.getElementById("attenuation-chart");
if (!canvas) { return; }
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.suspect &&
r.attenuation !== null && r.attenuation !== undefined;
});
if (!done.length) { return; }
new Chart(canvas.getContext("2d"), {
type: "bar",
data: {
labels: done.map(function (r) {
var name = r.canonical_name || ("Run " + r.beer_run_id);
// Long names were clipped at the left edge of
// the plot when rotated. Trim them to a
// readable length; the tooltip and the table
// below carry the full name.
if (name.length > 18) {
name = name.slice(0, 17) + "\u2026";
}
return name;
}),
datasets: [{
label: "Attenuation %",
data: done.map(function (r) { return r.attenuation; }),
backgroundColor: withAlpha(ACCENT, 0.65),
borderColor: ACCENT,
borderWidth: 1,
borderRadius: 4
}]
},
options: baseOptions({
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) {
return ctx.parsed.y.toFixed(1) + "% attenuated";
}
}
}
},
scales: {
y: {
beginAtZero: true,
// Attenuation cannot exceed 100%, so the axis
// is capped there. Without the cap a single
// bad reading would squash every other bar
// into an unreadable stub.
suggestedMax: 100,
title: { display: true, text: "% attenuated" }
},
x: {
// The names are long and the chart is narrow,
// so the labels are trimmed above and
// rotated. autoSkip keeps them from
// overlapping; the tooltip carries the
// full name for every bar.
ticks: {
maxRotation: 45,
minRotation: 45,
autoSkip: true,
autoSkipPadding: 8
}
}
}
})
});
})
.catch(function (err) { console.warn(err); });
}
/* -------------------------------------------- fermentation curve overlay */
function drawCurveChart() {
var canvas = document.getElementById("curve-chart");
if (!canvas) { return; }
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.suspect && r.curve && r.curve.length > 1;
});
if (!withCurve.length) { return; }
// Plotting every brew individually gives 16 near-identical
// lines and an unreadable legend. Instead: a MEDIAN curve
// with the min/max envelope behind it, which shows the
// typical shape and the spread without the clutter.
//
// Each curve is the fraction of its own gravity drop
// completed, so brews with different starting gravities can
// be compared directly.
var samples = 21; // 0%, 5%, ... 100%
// Interpolate each brew's curve onto the common progress axis.
//
// The brew's readings are at its own progress values (say
// 0%, 40%, 100%), so each sample point is interpolated
// BETWEEN the two readings that bracket it. Looking the
// value up by an exact key only works when every brew
// happened to be read at the same progress points, which is
// why the first attempt produced three points.
function interpolate(curve, pct) {
if (!curve.length) { return null; }
if (pct <= curve[0].x) { return curve[0].y; }
var last = curve[curve.length - 1];
if (pct >= last.x) { return last.y; }
for (var k = 0; k < curve.length - 1; k++) {
var a = curve[k];
var b = curve[k + 1];
if (pct >= a.x && pct <= b.x) {
var span = b.x - a.x;
if (span === 0) { return a.y; }
return a.y + (b.y - a.y) * ((pct - a.x) / span);
}
}
return last.y;
}
// The raw readings of each brew, sorted by progress.
var rawCurves = withCurve.map(function (r) {
var points = r.curve.map(function (p) {
return {
x: p.progress * 100,
y: parseFloat(p.sg_decimal)
};
}).filter(function (p) { return !isNaN(p.y); });
points.sort(function (a, b) { return a.x - b.x; });
return points;
}).filter(function (c) { return c.length > 1; });
if (!rawCurves.length) { return; }
// The interpolated value of every brew at every sample point.
var sampled = rawCurves.map(function (curve) {
var out = [];
for (var i = 0; i < samples; i++) {
var pct = (i / (samples - 1)) * 100;
var y = interpolate(curve, pct);
if (y === null) { continue; }
out.push({ x: pct, y: y });
}
return out;
}).filter(function (c) { return c.length > 1; });
if (!sampled.length) { return; }
// The envelope: the highest and lowest gravity at each
// progress point.
var envelope = [];
var median = [];
for (var i = 0; i < samples; i++) {
var pct = (i / (samples - 1)) * 100;
var values = [];
sampled.forEach(function (curve) {
var match = curve.filter(function (p) {
return Math.abs(p.x - pct) < 0.01;
});
if (match.length) { values.push(match[0].y); }
});
if (!values.length) { continue; }
values.sort(function (a, b) { return a - b; });
envelope.push({
x: pct,
y: values[0],
yMax: values[values.length - 1]
});
var mid = Math.floor(values.length / 2);
median.push({
x: pct,
y: values.length % 2
? values[mid]
: (values[mid - 1] + values[mid]) / 2
});
}
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
datasets: [
{
label: "Range (min–max)",
data: envelope,
borderColor: withAlpha(ACCENT, 0.35),
backgroundColor: withAlpha(ACCENT, 0.12),
borderWidth: 1,
pointRadius: 0,
fill: "+1",
tension: 0.3
},
{
label: "Median brew",
data: median,
borderColor: ACCENT_BRIGHT,
backgroundColor: withAlpha(ACCENT_BRIGHT, 0.15),
borderWidth: 2.5,
pointRadius: 3,
pointBackgroundColor: ACCENT_BRIGHT,
fill: false,
tension: 0.3
}
]
},
options: baseOptions({
plugins: {
legend: {
display: true,
labels: {
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
font: { size: 11 }
}
},
tooltip: {
callbacks: {
label: function (ctx) {
var point = ctx.parsed;
if (ctx.datasetIndex === 0) {
return "range " + point.y +
" to " + (point.yMax || point.y);
}
return "median " + point.y;
}
}
}
},
scales: {
x: {
// The x values are NUMERIC (0-100), so the
// axis must be linear. Chart.js defaults a
// line chart's x-axis to category, which
// collapses every point onto index 0 and
// renders the curve as a sliver at the
// origin.
type: "linear",
title: { display: true, text: "% of gravity drop" },
min: 0,
max: 100,
ticks: { stepSize: 10 }
},
y: {
// NOT reversed. Specific gravity falls as
// the beer ferments, and the scale puts the
// HIGH reading at the top by default, so the
// curve descends left to right - the way a
// brewer reads a fermentation. With
// reverse:true the curve climbed instead,
// which read as gravity rising.
title: { display: true, text: "Specific gravity" }
}
}
})
});
})
.catch(function (err) { console.warn(err); });
}
function init() {
drawSparklines();
drawRunChart();
drawAbvChart();
drawMonthsChart();
drawAttenuationChart();
drawCurveChart();
}
if (document.readyState === "loading") {
+221
View File
@@ -0,0 +1,221 @@
{% extends 'base.html' %}
{% block title %}Analysis — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Analysis</h1>
<p class="page__sub">How consistently the brews ferment, and how long each one takes.</p>
</div>
<div class="btn-row">
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
</div>
</div>
{% if not rows %}
<div class="card">
<div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<p><strong>No finished brews to analyse yet.</strong></p>
<p class="small">Once a run has a final reading it appears here.</p>
</div>
</div>
{% else %}
<div class="grid grid--4 mb-lg">
<div class="stat">
<div class="stat__label">Average attenuation</div>
<div class="stat__value">
{% if stats.avg_attenuation is not none %}{{ stats.avg_attenuation }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">of the original gravity fermented</div>
</div>
<div class="stat">
<div class="stat__label">Spread</div>
<div class="stat__value">
{% if stats.consistency %}±{{ stats.consistency.stdev }}pp{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.consistency %}
standard deviation of attenuation, lower is better
{% else %}
needs at least two brews
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Average brew time</div>
<div class="stat__value">
{% if stats.avg_days is not none %}{{ stats.avg_days }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">first reading to final reading</div>
</div>
<div class="stat">
<div class="stat__label">Time to 1.010</div>
<div class="stat__value">
{% if stats.avg_days_to_1010 is not none %}{{ stats.avg_days_to_1010 }}d{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.reached_1010 %}
average of {{ stats.reached_1010 }} brew{{ '' if stats.reached_1010 == 1 else 's' }} that got there
{% else %}
no brew reached 1.010
{% endif %}
</div>
</div>
</div>
<div class="grid grid--2 mb-lg">
<section class="card">
<div class="card__head">
<h2 class="card__title">Attenuation by brew</h2>
<span class="card__hint">How much of the original gravity was fermented away</span>
</div>
<div class="card__body">
<canvas id="attenuation-chart" height="130"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Fermentation curves</h2>
<span class="card__hint">Progress through fermentation, 0% to 100%</span>
</div>
<div class="card__body">
<canvas id="curve-chart" height="130"></canvas>
</div>
</section>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Every finished brew</h2>
<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">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">ABV</th>
<th class="num">Attenuation</th>
<th class="num">Days</th>
<th class="num">To 1.010</th>
<th>Finished</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
<td class="mono">{{ row.beer_run_id }}</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) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.fg_points is not none %}
{{ '%.3f' % (row.fg_points / 1000) }}
{% else %}—{% endif %}
</td>
<td class="num">
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
</td>
<td class="num">
{% if row.attenuation is not none %}
<strong>{{ row.attenuation }}%</strong>
{% else %}—{% endif %}
</td>
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% if suspect %}
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Readings that need a second look</h2>
<span class="card__hint">{{ suspect | length }} run{{ '' if suspect | length == 1 else 's' }} excluded from the averages</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{% for row in suspect %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
<td class="comment">{{ row.problem }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
{% if stats.consistency %}
<section class="card">
<div class="card__head">
<h2 class="card__title">What the numbers say</h2>
</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
<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 }}
percentage points.
{% if stats.consistency.stdev <= 3 %}
That is a tight spread — the process is repeatable.
{% elif stats.consistency.stdev <= 6 %}
That is a moderate spread — some variation between batches.
{% else %}
That is a wide spread — something in the process is not
consistent between batches.
{% endif %}
</p>
{% if stats.fastest and stats.slowest and stats.fastest != stats.slowest %}
<p>
The quickest brew was <strong>{{ stats.fastest.name }}</strong> at
{{ stats.fastest.days }} days; the longest was
<strong>{{ stats.slowest.name }}</strong> at
{{ stats.slowest.days }} days.
</p>
{% endif %}
{% if stats.avg_days_to_1010 %}
<p>
On average a brew reaches gravity 1.010 after
{{ stats.avg_days_to_1010 }} days — the number to plan around when
deciding when to check on a fermenter.
</p>
{% endif %}
</div>
</section>
{% endif %}
{% endif %}
{% endblock %}
+2
View File
@@ -23,6 +23,8 @@
href="{{ url_for('analysis.active') }}">Active Beers</a>
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
href="{{ url_for('analysis.history') }}">History</a>
<a class="nav__link {% if current_path == '/analysis' %}is-active{% endif %}"
href="{{ url_for('analysis.deep') }}">Analysis</a>
</nav>
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
+110 -4
View File
@@ -7,7 +7,8 @@ from datetime import date
from flask import Blueprint, redirect, render_template, request, session, url_for
from brewing import Run, is_plausible_sg, run_summary
from brewing import (Run, attenuation_percent, days_to_target, implausible_runs,
is_plausible_sg, run_summary)
bp = Blueprint("analysis", __name__)
@@ -32,6 +33,101 @@ def login_required(fn):
return wrapper
@bp.route("/analysis")
@login_required
def deep():
"""The deeper analysis: how the brews compare, and how long they take.
This is the page that answers the questions the history page cannot:
how consistent is my attenuation, which brew fermented fastest, and what
do the gravity curves look like side by side.
"""
runs = repo().runs()
metadata = repo().all_run_metadata()
finished = [r for r in runs if not r.is_active()]
# Runs whose gravity readings are physically impossible are excluded from
# 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:
meta = metadata.get(run.beer_run_id, {})
summary = _decorate(run, metadata)
rows.append({
"beer_run_id": run.beer_run_id,
"name": meta.get("name") or run.display_name(),
"final_date": summary["final_date"],
"first_date": summary["first_date"],
"og_points": summary["og_points"],
"fg_points": summary["fg_points"],
"abv": summary["abv"],
"attenuation": attenuation_percent(run),
"days": summary["days_in_fermenter"],
"days_to_1010": days_to_target(run, 1010),
"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"]]
# 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
# repeatable; a wide one means something is inconsistent.
consistency = None
if len(attenuations) >= 2:
mean = sum(attenuations) / len(attenuations)
variance = sum((a - mean) ** 2 for a in attenuations) / len(attenuations)
consistency = {
"mean": round(mean, 1),
"stdev": round(variance ** 0.5, 1),
"spread": round(max(attenuations) - min(attenuations), 1),
}
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 good if r["days"] is not None),
key=lambda r: r["days"], default=None)
return render_template(
"analysis.html",
rows=rows,
curves=curves,
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,
"avg_days_to_1010": _fmt_days(sum(to_1010) / len(to_1010)) if to_1010 else None,
"reached_1010": len(to_1010),
"consistency": consistency,
"fastest": fastest,
"slowest": slowest,
},
)
def _month_range(first: str, last: str) -> list[str]:
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
@@ -113,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 = []
@@ -159,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",
+10 -1
View File
@@ -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)
@@ -202,6 +208,9 @@ def api_run(run_id: str):
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
# 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]))
return jsonify(summary)
+11
View File
@@ -53,6 +53,17 @@ def repo(mongo_client):
return BeerRepository(client=mongo_client)
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
def seed_readings(repository, rows):
"""Insert reading documents directly, bypassing the app.
+394
View File
@@ -0,0 +1,394 @@
"""Tests for the deeper analysis: attenuation, fermentation curves, timings."""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from brewing import (Run, attenuation_percent, days_to_target,
fermentation_curve)
from conftest import seed_readings
def _doc(run_id="1", beer_type="Ale", sg="1045", date_="2026-01-01",
final="False", comment=""):
return {
"_id": f"id-{run_id}-{date_}-{sg}",
"beer_run_id": run_id,
"beer_type": beer_type,
"sg": sg,
"date": date_,
"final_reading": final,
"comment": comment,
}
# ------------------------------------------------------------ attenuation #
def test_attenuation_percent():
"""Attenuation is the fraction of the original gravity fermented away."""
# OG 1050, FG 1010: 40 of the 50 points above 1.000 went away.
run = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) == 80.0
def test_attenuation_percent_of_a_full_fermentation():
"""A wort that finishes at 1.000 has 100% attenuation."""
run = Run.from_docs("2", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1000", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) == 100.0
def test_attenuation_percent_needs_a_gravity_above_1000():
"""A run that started at or below 1.000 has no meaningful attenuation."""
run = Run.from_docs("3", [
_doc(sg="1000", date_="2026-01-01"),
_doc(sg="0995", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run) is None
def test_attenuation_percent_of_an_unfinished_run_uses_its_current_gravity():
"""The current gravity stands in for the final one until the run ends."""
run = Run.from_docs("4", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-10"),
])
assert attenuation_percent(run) == 60.0
# ------------------------------------------------------- fermentation curve #
def test_fermentation_curve_is_empty_for_a_single_reading():
"""One reading is no curve."""
run = Run.from_docs("5", [_doc(sg="1045", date_="2026-01-01")])
assert fermentation_curve(run) == []
def test_fermentation_curve_progresses_from_zero_to_one():
"""The curve is the fraction of the gravity drop completed."""
run = Run.from_docs("6", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1030", date_="2026-01-10"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
curve = fermentation_curve(run)
assert len(curve) == 3
assert curve[0]["progress"] == 0.0
assert curve[1]["progress"] == 0.5
assert curve[2]["progress"] == 1.0
def test_fermentation_curve_carries_the_reading_details():
run = Run.from_docs("7", [
_doc(sg="1050", date_="2026-01-01", comment="brew day"),
_doc(sg="1010", date_="2026-01-20", final="True", comment="kegging"),
])
curve = fermentation_curve(run)
assert curve[0]["date"] == "2026-01-01"
assert curve[0]["sg_decimal"] == "1.050"
assert curve[0]["point_scale"] == "50"
assert curve[0]["comment"] == "brew day"
assert curve[1]["final"] is True
def test_fermentation_curve_is_empty_when_the_gravity_never_falls():
"""A run that only goes up has no drop to measure progress against."""
run = Run.from_docs("8", [
_doc(sg="1010", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-10", final="True"),
])
assert fermentation_curve(run) == []
# ------------------------------------------------------------- days to target #
def test_days_to_target():
"""How long the brew took to reach a given gravity."""
run = Run.from_docs("9", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1020", date_="2026-01-08"),
_doc(sg="1005", date_="2026-01-20", final="True"),
])
# 1020 is still above 1015, so the run does not reach 1015 until the
# third reading, 19 days in.
assert days_to_target(run, 1015) == 19
assert days_to_target(run, 1010) == 19
# 1020 is below 1025, so that target is reached on the second reading.
assert days_to_target(run, 1025) == 7
assert days_to_target(run, 1000) is None
def test_days_to_target_returns_none_when_never_reached():
run = Run.from_docs("10", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1040", date_="2026-01-20", final="True"),
])
assert days_to_target(run, 1010) is None
# ------------------------------------------------------------------- summary #
def test_run_summary_includes_the_new_analysis_fields():
from brewing import run_summary
run = Run.from_docs("11", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
summary = run_summary(run)
assert summary["attenuation"] == 80.0
assert len(summary["curve"]) == 2
assert summary["curve"][0]["progress"] == 0.0
def test_attenuation_is_none_for_a_final_gravity_below_water():
"""Beer cannot be less dense than water. Run 12 in the live data has a
final gravity of 900, which would otherwise show as 338% attenuation."""
run = Run.from_docs("12", [
_doc(sg="1042", date_="2025-10-05"),
_doc(sg="900", date_="2025-11-30", final="True"),
])
assert attenuation_percent(run) is None
def test_attenuation_is_none_when_it_would_exceed_100_percent():
run = Run.from_docs("13", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1000", date_="2026-01-20", final="True"),
])
# 1050 -> 1000 is exactly 100%, which is the limit but still valid.
assert attenuation_percent(run) == 100.0
run2 = Run.from_docs("14", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert attenuation_percent(run2) == 80.0
def test_implausible_runs_are_identified():
from brewing import implausible_runs
good = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
bad = Run.from_docs("12", [
_doc(sg="1042", date_="2025-10-05"),
_doc(sg="900", date_="2025-11-30", final="True"),
])
problems = implausible_runs([good, bad])
assert len(problems) == 1
assert problems[0]["beer_run_id"] == "12"
assert problems[0]["fg_points"] == 900
assert "below 1.000" in problems[0]["problem"]
def test_implausible_runs_ignores_good_runs():
from brewing import implausible_runs
good = Run.from_docs("1", [
_doc(sg="1050", date_="2026-01-01"),
_doc(sg="1010", date_="2026-01-20", final="True"),
])
assert implausible_runs([good]) == []
# ------------------------------------------------------- chart rendering #
def test_charts_do_not_reverse_the_gravity_axis():
"""Gravity must FALL on screen as the beer ferments.
The gravity charts originally set `reverse: true` on the y axis, which
puts the HIGH reading at the bottom of the plot - so the fermentation
curve climbed left to right and read as gravity *rising*. A brewer
reads a fermentation as a descending line, so the axis must not be
reversed. The point-scale run chart had the same bug.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "reverse: true" not in charts_js, (
"a gravity chart is reversing the y axis; the fermentation curve "
"would climb instead of descend"
)
def test_attenuation_chart_truncates_long_names():
"""Long beer names were clipped at the left edge of the plot.
The rotated x-axis labels were cut off ("...x crying out loud"), so the
names are trimmed to a readable length before they are drawn. The
tooltip and the table below carry the full name.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert "name.length > 18" in charts_js, (
"the attenuation chart is not trimming long beer names"
)
# ------------------------------------------- suspect runs are not counted #
def _two_runs(repo):
"""One good brew and one with an impossible final gravity.
The bad run mimics the live data: run 12 has a final gravity of 900,
which is below water and makes the run look like a 56-day brew that
reached 18.64% alcohol.
"""
seed_readings(repo, [
("1", "Good ale", "1050", "2026-01-01", False, ""),
("1", "Good ale", "1010", "2026-01-10", True, ""),
# 56 days, and a final gravity below 1.000.
("12", "Bad brew", "1042", "2026-01-01", False, ""),
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
])
def _stat_values(body):
"""The figures in the analysis stat tiles, in order.
The tiles are rendered as label/value pairs, so the numbers can be
pulled out without depending on the exact markup.
"""
import re
values = re.findall(r'<div class="stat__value">([^<]+)</div>', body)
return [v.strip() for v in values]
def test_a_suspect_run_is_excluded_from_the_brew_time_average(logged_in, repo):
"""The 56-day bad run must not drag the average brew time up.
This was the review finding: the run was excluded from the attenuation
average but not from the brew-time average, so one bad reading was
poisoning two different statistics.
"""
_two_runs(repo)
response = logged_in.get("/analysis")
assert response.status_code == 200
body = response.get_data(as_text=True)
values = _stat_values(body)
assert values, "the analysis page rendered no stat tiles"
# The stat tiles are: average attenuation, spread, average brew time,
# time to 1.010. The good run took 9 days; with the bad run folded
# in the average would be (9 + 56) / 2 = 32.5 days.
assert "9d" in values, f"expected a 9 day brew time, got {values}"
def test_a_suspect_run_is_excluded_from_the_time_to_1010_average(logged_in, repo):
"""Same for the "time to 1.010" figure."""
_two_runs(repo)
response = logged_in.get("/analysis")
body = response.get_data(as_text=True)
values = _stat_values(body)
assert values
# The good run reached 1.010 in 9 days; the bad one "reached" it in 56.
assert "9d" in values, f"expected 9 days to 1.010, got {values}"
def test_the_suspect_run_is_still_listed_for_review(logged_in, repo):
"""Excluding it from the averages must not hide it.
The run has to stay visible so it can be corrected.
"""
_two_runs(repo)
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_a_suspect_run_is_flagged_in_the_api(logged_in, repo):
"""The charts fetch /api/runs, so the flag has to be in the payload."""
_two_runs(repo)
response = logged_in.get("/api/runs")
assert response.status_code == 200
payload = response.get_json()
by_run = {str(r["beer_run_id"]): r for r in payload}
assert by_run["12"]["suspect"] is True
assert by_run["1"]["suspect"] is False
def test_a_suspect_run_is_not_the_strongest_brew_in_the_history(logged_in, repo):
"""The history page showed the bad run as the strongest beer.
An impossible final gravity produced an 18.64% ABV, which made run 12
the "strongest" brew in the history and pulled the average up.
The run's own row still shows its ABV - it is real data, and hiding it
would be worse - but it must not be counted as the strongest brew, and
it must not appear in the "Strongest" figure.
"""
_two_runs(repo)
response = logged_in.get("/history")
assert response.status_code == 200
body = response.get_data(as_text=True)
# The "Strongest" tile shows the good run's 4.76%, not the bad
# run's 18.64%. Find the Strongest tile and read its value.
import re
strongest = re.search(
r"Strongest.*?stat__value[^>]*>([^<]+)<", body, re.DOTALL
)
assert strongest, "the history page has no Strongest figure"
assert "18.64" not in strongest.group(1), (
f"the suspect run was counted as the strongest brew: {strongest.group(1)}"
)
def test_the_run_detail_api_also_flags_a_suspect_run(logged_in, repo):
"""The detail endpoint carries the same flag as the list endpoint.
A consumer of /api/runs/<run_id> must be able to tell an impossible
run from a good one.
"""
_two_runs(repo)
response = logged_in.get("/api/runs/12")
assert response.status_code == 200
assert response.get_json()["suspect"] is True
response = logged_in.get("/api/runs/1")
assert response.status_code == 200
assert response.get_json()["suspect"] is False
def test_the_charts_skip_suspect_runs():
"""The chart script must leave the suspect runs out.
A suspect run's fermentation curve plunges to a gravity below water,
which would drag the median line down and stretch the envelope.
"""
charts_js = (
Path(__file__).resolve().parents[1]
/ "src" / "flask" / "static" / "js" / "charts.js"
).read_text()
assert charts_js.count("!r.suspect") >= 2, (
"the charts must filter out the runs flagged as suspect"
)
+62 -11
View File
@@ -7,17 +7,6 @@ import pytest
from conftest import seed_readings
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
# ------------------------------------------------------------------ public #
def test_healthz_is_public(client):
@@ -639,3 +628,65 @@ def test_month_range_covers_a_year_boundary():
"2025-11", "2025-12", "2026-01", "2026-02",
]
assert _month_range("2026-05", "2026-05") == ["2026-05"]
# ------------------------------------------------------------- deep analysis #
def test_the_analysis_page_renders(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
])
response = logged_in.get("/analysis")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Ginger Beer" in body
# The attenuation figures and the summary sentence.
assert "80.0%" in body
assert "attenuation" in body.lower()
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
"""An unfinished brew has no final reading, so it is not comparable."""
seed_readings(repo, [
("1", "Finished", "1050", "2026-01-01", False, ""),
("1", "Finished", "1010", "2026-01-20", True, ""),
("2", "Still Going", "1045", "2026-02-01", False, ""),
])
body = logged_in.get("/analysis").data.decode()
assert "Finished" in body
assert "Still Going" not in body
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
response = logged_in.get("/analysis")
assert response.status_code == 200
assert b"No finished brews" in response.data
def test_the_analysis_page_is_protected(client):
response = client.get("/analysis")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
"""The charts fetch the curve data from the API."""
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
])
payload = logged_in.get("/api/runs").get_json()
entry = payload[0]
assert entry["attenuation"] == 80.0
assert len(entry["curve"]) == 2
assert entry["curve"][0]["progress"] == 0.0
assert entry["curve"][1]["progress"] == 1.0