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.
Brew Log
Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into useful figures — alcohol by volume, days in the fermenter, gravity trends.
Flask app, Mongo backend, no build step for the front end.
Running it locally
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
export MONGO_HOST=192.168.178.229
export MONGO_USER=root
export MONGO_PASS=...
export SECRET_KEY=anything-for-local-use
cd src/flask
python -m beer_data # dev server on :5000
# or, production-style:
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app
Then open http://localhost:5000/ and sign in.
Tests
. .venv/bin/activate
python -m pytest tests/ -q
The tests use mongomock, so they need no database and no network.
The pages
| Page | What it is for |
|---|---|
/login |
Sign in |
/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
A card per brew that is still in the fermenter (a run with no reading marked final), showing:
- the tap number it is on,
- the current gravity, as a decimal reading with the point scale beneath,
- the projected alcohol, worked out from the run's original gravity,
- how many days it has been brewing,
- a sparkline of the gravity so far,
- the latest comment on a reading.
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
the beer_runs collection and edited on the run detail page:
- the canonical name the brew is known by (overriding the drifting per-reading name),
- the tap number,
- the original gravity,
- free-text brew notes.
Populating the run metadata from the existing readings
# dry run - prints what it would do, writes nothing
python scripts/backfill_run_metadata.py
# write it
python scripts/backfill_run_metadata.py --apply
The script is idempotent and non-destructive: it only inserts into
beer_runs, never touches beer_data, and never overwrites a name you have
already set by hand (pass --force if you want it to).
Layout
src/flask/
beer_data.py entry point; exposes `app` and `create_app`
app_factory.py application factory, request-scoped Mongo repository
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
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)
tests/ pytest suite
scripts/ operational scripts (the run-metadata backfill)
brewing.py holds every piece of arithmetic and has no Flask or Mongo
dependency, so the maths is unit-testable on its own.
Data model
beer_data — one document per gravity reading
| Field | Type | Notes |
|---|---|---|
_id |
string | 24 random characters |
beer_run_id |
string | The run this reading belongs to, e.g. "20" |
beer_type |
string | Beer/cider name. Free text, per reading — it drifts across a run |
sg |
string | Specific gravity in points: 1039 means 1.039 |
date |
string | YYYY-MM-DD |
final_reading |
string | "True" / "False" — marks the last reading of a run |
comment |
string | Free text |
The unique key is (date, beer_run_id): re-submitting a reading for the same
run and day updates it in place rather than creating a duplicate.
A run is active while it has no reading with final_reading == "True".
beer_runs — one document per run (metadata)
Added alongside the readings to hold what cannot be derived from them: the canonical beer name, the tap number, the recorded original gravity, and brew notes. This collection is only ever inserted into, never written over by the backfill.
Alcohol by volume
Standard homebrew formula:
ABV% = (OG - FG) × 131.25
With gravity stored in points, the drop is already in points, so
ABV% = points_drop × 0.13125.
The previous version of this app used
(max - min) / 7.36 + 0.5, which is not a recognised brewing formula and overstated every result. The old figure is still computed bybrewing.legacy_abv_percent()so the history page can show the difference between the two.
For a run still in the fermenter the figure is an estimate: it uses the run's first reading as the original gravity. A run with a single reading and no recorded OG has no reliable estimate, and the UI shows a dash rather than a misleading number.
Deployment
The image is built and pushed by .gitea/workflows/build_push.yml on push to
master. See RUNBOOK.md for the cluster side.