docs: add CLAUDE.md for future Claude Code sessions
Orients Claude Code fast in this repo: test/run commands and the component/task/factory, WebSocket, PID/FSM, Sud/forecast, and validation architecture, pointing back to README.md for deep detail. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9TKZynb4CdkwEh41kVZuy
This commit is contained in:
@@ -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://<server-host>:8080/
|
||||
|
||||
# Offline forecast-vs-actual replay from a saved log
|
||||
python utils/analyze_log.py <date_time> <sud_name> # 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/<mirror of source
|
||||
path>/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.
|
||||
Reference in New Issue
Block a user