# 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.