diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f4ad9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,151 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Python asyncio server (`server/brewpi.py`) that controls a mash/brewing rig: drives a +heater and stirrer to hold or ramp a pot of liquid to target temperatures per a +configurable mash schedule ("Sud"), and exposes live state/control over a WebSocket to a +PyQt5 desktop client (`client/`) and a dependency-free browser client (`web/`). It began as +a control-theory playground (Smith-predictor temperature control) and has grown real-hardware +backends (induction hob, RTD probe) alongside the simulation ones. + +**Read `README.md` before making non-trivial changes** — it documents the architecture, +protocol, and a long list of subtle behaviors (state-replay-on-connect, forecast +reanchoring, sim-warp-factor semantics, log field meanings) in detail this file doesn't +repeat. This file only orients you fast; README is the source of truth for *why* things +work the way they do. + +## Commands + +Run everything from the repo root. + +```bash +# Run the whole test suite (stdlib unittest, NOT pytest) +python -m unittest discover -t . -s tests + +# Run a single test file +python -m unittest tests.components.pid.test_pid + +# Run a single test case / method +python -m unittest tests.components.pid.test_pid.TestPidClamp.test_yi_clamped_to_max + +# Start the server (simulated plant, default config) +cp config-sim.json.tpl config.json # once, or config-real.json.tpl for real hardware +cd server && ./brewpi.py +cd server && ./brewpi.py --sim-warp-factor 50 # faster-than-real-time dev/testing +cd server && ./brewpi.py --http-port 8888 # browser client port + +# Desktop client +cd client && ./brewpi_gui.py +# Browser client: open http://:8080/ + +# Offline forecast-vs-actual replay from a saved log +python utils/analyze_log.py # e.g. 20260701T192824 Sud-0010 +python utils/analyze_log.py path/to/log_*.json +``` + +There is no build step, linter config, or packaging beyond `pip install -r +server/requirements.txt` (+ `client/requirements.txt` for the desktop GUI, + +`server/requirements-hw.txt` for real hardware backends). + +**Tests**: use stdlib `unittest`, not pytest. Tests live under `tests//test_*.py` (e.g. `tests/components/pid/` mirrors `components/pid/`) and every +directory needs an `__init__.py`. The `-t .` flag on `discover` is mandatory — without it, +`top_level_dir` defaults to `tests/`, which gets put on `sys.path` instead of the repo root, +and since test packages mirror real package names (`tests/components/pid` vs +`components/pid`), imports silently resolve against the wrong directory instead of failing +loudly. + +## Architecture + +### Component / task / factory pattern + +Almost everything under `components/` follows the same shape: an abstract base (e.g. +`AHeater`, `AStirrer`, `APid`, `AAplant`, `ATemperatureSensor`) defines the interface, and +concrete implementations (sim vs. real hardware, or PID variant) are picked at runtime via a +`*Factory.create(name, ...)` (`components/factory.py`'s `ComponentFactory`, matching `name` +against a registry by substring — e.g. `"sim"` in `plant_name` selects the whole simulated +heater/pot/sensor trio via `PlantFactory`). Adding a new backend means: subclass the ABC, +register it in the relevant factory, no changes needed anywhere that already calls the +factory. + +Each component that needs periodic work is wrapped in an `ATask` (`tasks/task.py`) — +`on_process()` runs on its own `--dt`/`--sim-warp-factor`-scaled interval as an asyncio task, +independent of every other task. `AttributeChange` (`utils/value.py`), the common base for +both tasks and components, is what makes an attribute observable: setting it fires +`set_on_changed` callbacks only if the value actually changed. `server/brewpi.py` is where +everything gets wired together — it builds the components via factories based on +`config.json`'s `Controller` section, then chains their outputs to each other's inputs via +`set_on_changed` (sensor temp → controller `theta_ist`, controller output → heater power, +heater effective power → pot model, etc.). + +### Server is the single source of truth + +Neither client ever maintains its own model of desired state. A client sends a command and +waits for the server's broadcast reply before updating its display — never an optimistic +local update. This is why reconnecting a client (or connecting a second one) needs no special +handshake: `ws/server/ws_server_multi_user.py`'s `global_state` accumulates every message +ever broadcast, keyed the same way the live channels are, and a fresh subscribe replays the +full current state for that channel in one message. See README's "State is server-only" +section for the full rationale and the `Error`-then-`Error: None` one-shot-event pattern this +implies for anything that isn't durable state. + +### PID / temperature control (`components/pid/`) + +Two controller variants (`pid_factory.py`): plain PID ("Normal") or PID + Smith predictor +("Smith", compensates for the pot's transport delay using two internal models). Both are a +4-state FSM (IDLE/HEAT/HOLD/COOL, `temp_controller_fsm.py`) with thresholds driven entirely +from `config.json`'s `TempCtrl.Thresholds` — never hardcoded. `components/pid/TODO.md` is the +live design-flaw backlog for this subsystem (windup, config validation gaps, etc.) — check it +before "fixing" something that touches PID/FSM behavior, since some oddities are known and +deliberately deferred rather than bugs. + +### Mash schedules ("Sud") + +`sude/*.json` files describe a brew: fixed pot/thermal params plus a `steps` list, each +optionally ramping to a `temperature` and/or holding for a `duration`. `components/sud.py`'s +`Sud` resolves each raw step against `default.step` and tracks progress; `tasks/sud.py`'s +`SudTask` drives the temperature controller toward each step's target, advancing only once +the controller's own FSM reports the gap closed. Schedules are loaded into server memory over +the WebSocket (`{"Sud": {"Load": ...}}`) — the server never reads/writes `sude/*.json` +itself, only the client's file dialog does. + +`components/sud_forecast.py`'s `SudForecastEstimator` runs a throwaway `Sud`/`Pot`/controller +trio through the whole schedule to predict its trajectory (for the GUI's forecast plot), +re-anchored at every real step transition (`SudTask._reanchor_forecast()`) so the displayed +forecast keeps converging with reality rather than showing one static, increasingly-wrong +guess made at Load time. This reanchoring logic, its race-condition guard +(`_forecast_generation`), and the coordinate-space subtleties around it are among the most +gotcha-prone parts of the codebase — read README's "Forecast vs. actual duration" section in +full before touching it. + +### Validation + +`utils/config_validate.py` / `utils/sud_validate.py` share a declarative `require_schema()` +engine (`utils/config_validate.py`) that walks a nested schema dict and raises `ConfigError` +named by full dotted path on a missing key or wrong type — used for both `config.json`'s +`TempCtrl` section and (with extra per-step conditional checks) `sude/*.json` documents. +Fixtures for both live under `tests/utils/fixtures/` and `tests/utils/sud_fixtures/`, one +fixture per failure mode, driving a fixture-parameterized test suite rather than one test per +assertion. + +### Logging + +Every task/component logs through stdlib `logging` (never `print()`) via `AttributeChange`'s +`self.log`. Two independent JSON loggers (`tasks/server_log.py`'s `ServerLogTask`, running for +the whole server session, and `tasks/sud_log.py`'s `SudLogTask`, a subclass active only during +a Sud run) record the six live-plotted signals plus enough context (`Config`, `HeaterPowers`, +effective `SimWarpFactor`, and for Sud logs the raw `Doc` and `ForecastAnchors`) to fully +reconstruct a forecast-vs-actual comparison offline via `utils/analyze_log.py`, with no live +server or hardware needed. Sample timestamps are always tick-counted (`dt`-based), never +wall-clock — see README's "Logging & analysis" section for why that matters for forecast +alignment. + +### `--sim-warp-factor` + +Only ever scales the *wait* time between task ticks — never `--dt` itself, and it has no +effect at all against real hardware (clamped to `1.0` whenever the plant isn't simulated). +Never let a change reintroduce a path where this scales a plant-model constant or a recorded +timestamp.