Files
brewpi/CLAUDE.md
T
jensandClaude Sonnet 5 3a1184ef63 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
2026-07-26 23:00:36 +02:00

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.