Files
beer_data/README.md
T
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

178 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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
```bash
. .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
```bash
# 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 by `brewing.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](RUNBOOK.md) for the cluster side.