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