Each log_*.json now carries a "Config" key (the full server config) and
a "PlantParams" list of {t, params} entries — one per step transition —
so offline analysis tools have the PID gains and plant model that were
active at every point of the run without having to cross-reference a
separate config file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
During hold, a small temperature overshoot causes pid_hold.y to go
slightly negative, inverting heatrate_soll and driving pid_heat's
power to 0. With no power the pot coasts back down, pid_hold goes
positive again, power builds back up, overshoot repeats — a ~110s
limit cycle visible as pumping in the power trace.
Clamping pid_hold.y to [0, 1] in HOLD state lets the outer loop
reduce the inner rate setpoint to zero (stop adding heat) but not
invert it (actively demand cooling), breaking the limit cycle while
preserving normal HEAT/COOL cascade behaviour.
Note: Hold.kt must remain 0 when Hold.ki=0 — a non-zero kt drains
pid_hold.yi during ramp saturation, suppressing heatrate_soll at the
start of each hold phase and leaking into the next ramp via the
bumpless HOLD→HEAT transfer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two root causes for the visible power dip at every ramp-to-ramp
step boundary:
1. One-tick cascade delay: heatrate_soll was computed from
pid_hold.get_y() before pid_hold.process() ran in that tick,
so the first tick of a new ramp used the stale hold-phase
output (≈0) as the rate setpoint, driving pid_heat to near
zero. Fix: move heatrate_soll computation inside process_pid(),
after pid_hold.process().
2. Cold-start reset: pid_heat.reset() at HOLD→HEAT discarded
the hold-phase integral, so pid_heat had to ramp up from
zero on every new step. Fix: remove the reset (bumpless
transfer) — the hold-phase integral is a warm starting point
that carries the baseline hold power into the new ramp.
Together these eliminate the ~1s dip to near-zero and replace
the slow 20–30s integral ramp-up with an immediate start at
roughly hold-level power, climbing monotonically to the target.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pot config (water_mass, volumen) sent in System message at startup.
app.js stores potConfig and uses it in updatePotVisualization/
updateStatusLine when sudEmpty, showing startup fill level and
"No schedule – N L water" without requiring a sud to be loaded first.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sud._parse_data now falls through to pot_config for all hardware keys
(mass, material, L, Td) — EMPTY_SUD no longer shadows them. brewpi.py
seeds pot/tc plant params from Pot.water_mass on startup so the
simulated plant is configured before any sud is loaded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pot hardware constants (mass, material, L, Td) moved from sude/*.json
to config.json's Pot section; Sud/SudForecastEstimator accept pot_config
as baseline, per-brew pot sections may still override. Controller key
removed; pid_type moved into TempCtrl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PlantFactory.create() no longer takes plant_name; sim vs real is derived
from Heater.type. StirrerFactory is wired from Stirrer.type. HeaterFactory
registry key lowercased to "hendi" to match config. Controller.plant_name
removed from both config templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PlantFactory.create() now accepts sensor_config dict; extracts type and
kwargs, falls back to sigma=0.05/temp_offset=-0.15 defaults. brewpi.py
passes config.get('TempSensor', {}). Templates updated with full
TempSensor sections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the opaque variance/√12 formula with two explicit noise components:
sigma (default 0.05 °C) — white Gaussian noise, calibrated against the
20260628T184903 Sud-0010 log (detrended hold-phase tick-to-tick std ≈ 0.053 °C).
stirrer_sigma / stirrer_tau — optional AR(1) low-frequency component for
stirrer-induced fluctuations (off by default); steady-state std equals
stirrer_sigma, correlation time equals stirrer_tau ticks.
plant_factory.py updated to use sigma=0.05 instead of the old variance=0.01
(which gave std ≈ 0.003 °C — far too quiet).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The two-argument form (date_time sud_name) is unchanged. When called
with a single argument it is treated as a direct path to any log_*.json
(e.g. a server-session log without a Sud name); the forecast panel is
shown only if a matching forecast_*.json exists alongside it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ServerLogTask (tasks/server_log.py) which records temp/power samples
from startup to shutdown regardless of Sud state, in the same Name/Samples
JSON format as SudLogTask so analyze_log.py and replay_sim.py can consume
it directly. The file is written on graceful exit (Ctrl-C) from brewpi.py's
finally block, just before the heater is closed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On KeyboardInterrupt, brewpi.py cancels all asyncio tasks (letting
HeaterTask's `with device.open()` finally block run), then calls
heater.close() as belt-and-suspenders before closing the event loop.
On shutdown HeaterHendi.activate(False) now sets the switch to 0 instead
of disabling remote control, so the Hendi stays in remote mode at 0 W
rather than dropping back to its local panel state. HendiCtrl.close()
does the same (setSwitch(0)) then closes the serial port.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a configurable IIR pre-filter (beta, default 0.05, τ≈19 s at dt=1 s)
applied to theta_ist before the derivative, so stirrer-induced temperature
noise is attenuated before being amplified by differentiation rather than
after. The shared filter/derivative/post-filter logic is factored into
TempControllerBase._compute_heatrate(); both Normal and Smith subclasses
call it, replacing their duplicated inline rate blocks.
beta is read from TempCtrl.beta in the config (default 0.05 if absent) and
added to config-sim.json.tpl and config-real.json.tpl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the open-loop (feed real temp_ist) approach with a proper
closed-loop simulation: a Pot plant model is driven by the controller's
power output each tick, generating sim_temp_ist that diverges from the
real log's real_temp_ist whenever the PID params produce different
behaviour. Temperature panel now shows real_temp_ist vs sim_temp_ist
vs temp_soll so the effect of candidate gains is immediately visible.
Plant params (M, C, L, Td) default to 20 kg water / L=0.2 / Td=30 s
and can be overridden via --plant-* flags. Ambient defaults from config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
Feeds real temp_ist/temp_soll readings from a run log back into a fresh
TempController (Normal mode) tick by tick, then plots a 3-panel comparison
of heat rate and normalized power against the original logged values. Any
PID gain can be overridden via --<section>-<gain> flags so candidate params
can be evaluated against a past run without touching hardware.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EY449HByk6UpKhDN2HTnfK
Documents the stirring-induced rate noise problem, its root cause
(post-differentiation filtering), and three mitigation options: pre-filter
before differentiating, sliding-window regression, and model-based rate
from Pot.p_pot (Smith only).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
Renders the same two plots the PyQt GUI shows (realtime 3-panel strip
chart and forecast-vs-actual comparison) from a log/forecast JSON pair,
specified by date_time and sud_name on the command line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSo8R6GjoBQdStB3j67zSs
pyserial and spidev are Linux/Pi-only (spidev requires the kernel SPI
driver and won't install on macOS/Windows). Dev machines use
requirements.txt; the Pi uses requirements-hw.txt which pulls in the
base file via -r.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
HoldRemaining now calls updateHeaderCountdown() directly so the header
hold row refreshes every second from its own message, not only via the
Elapsed → updateStepPlates → updateHeaderCountdown chain.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Power soll label centered below flame area (amber, clears when off)
- Stirrer RPM label next to motor housing (clears when stopped)
- Flame base moved 20 units closer to pot bottom
- Drop Ist/Soll LCD header row to save vertical space
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Catch ConnectionClosed in User.send() and handler_recv() so that an
unclean client disconnect no longer produces "Task exception was never
retrieved" / "connection handler failed" log noise.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Add viewport meta tag so Safari uses device CSS pixels
- Move LCD units to a dedicated right-hand column; strip them from labels
- Tighten LCD grid: 4-column layout, narrower gap, font-size 1.9em
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- HeaterTask.shutdown() on DONE/IDLE: document the _on_end callback,
power-zero, TC disable, and ClosedLoop:false broadcast to clients.
- Resume with inherited temperature: document _paused_temp_soll fallback
for steps that carry no temperature of their own.
- Browser client additional features: update Header countdown (now three
rows: total/ramp/hold, always h:mm:ss) and describe the step-plate
Ramp/Hold green countdown and configured hold-duration field.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Each step plate now shows the scheduled hold time ("Hold h:mm:ss") as a
static field instead of the live actual temperature. The value is set once
at plate-creation time from the step's hold.duration; steps without a hold
phase show "Hold —".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Step plates now show "Ramp h:mm:ss" (ramp time only) during ramp phase
and "Hold h:mm:ss" (hold remaining) during hold phase, both in the same
digit area in green (#4ade80). Inactive steps show planned ramp duration.
Header countdown gains a "hold" row alongside the existing "ramp" (renamed
from "step") and "total" rows. All three use the same h:mm:ss format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Adds HeaterTask.shutdown() which switches to open-loop at power 0,
disables TC, and broadcasts ClosedLoop=false to clients so the browser
checkbox unchecks immediately. SudTask fires it via a new set_on_end()
callback whenever state reaches DONE or IDLE, giving both a completed
schedule and a manual Stop the same safe behaviour: stirrer and heater
both off, no heater left running unattended.
Re-enabling after shutdown: check the closed-loop checkbox (sends
ClosedLoop=true → TC re-enabled) or adjust the power slider directly;
the next brew's Play press already sends ClosedLoop=true automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Total remaining now appears above step remaining in the header. Both
countdowns and all other duration displays always use h:mm:ss format
regardless of whether hours is zero, so the layout never shifts width.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Steps with no 'temperature' field inherit their target from a prior step
(temperature=None in the resolved schedule). The resume-from-pause code
only called set_theta_soll when the current step had an explicit temperature,
so any manual setpoint change made during pause was never reverted for
inherited steps (e.g. sud_0010 steps 2-3 both hold at step 1's 57 °C).
Fix: save theta_soll_set at pause time; use it as fallback on resume when
the current step's temperature is None. Also set initialTempSollSync=true
in the browser on play so the slider accepts the next server Soll push
regardless of message arrival order relative to the State update.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
- Rename "State replay on connect" to "State is server-only; GUIs only
reflect it" and expand it with the core design principle: neither GUI
client ever maintains its own model of server state — controls update
only on server push, never speculatively on user input. Multi-client
sync, stale-overwrite safety, and reconnect correctness all follow from
this single rule.
- Update TC-enable description: HeaterTask now owns the TC's enabled
switch via the closed_loop flag; SudTask no longer touches it.
- Add "Heater control modes" subsection describing Closed-loop/Open-loop,
the enable/disable rules per mode and sud state, the auto-reset on play,
and the schedule parameter restore on resume.
- Refresh the Browser client section: document the Heater control mode
checkbox, header countdown timers, and the always-reflect-server-values
slider policy; call out the server-only-state principle explicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
On resume from PAUSED, on_step_changed does not re-fire (same step
continues), so any manual TC/stirrer adjustments made while paused
would persist into the run. Explicitly re-apply the current step's
temperature, heatrate, and stirrer config on the resume path in
SudTask.recv().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Closed-loop (default, checkbox checked): TC has exclusive control over
the heater; temp-soll and heatrate-soll sliders are active; direct
heater power entry is disabled. TC now stays enabled at server startup
and whenever closed-loop mode is active — SudTask no longer disables it
on IDLE/DONE, so adjusting temp-soll manually with no sud running
immediately drives the heater via the TC.
Open-loop (checkbox unchecked, only selectable when paused/stopped):
TC is disconnected from the heater; only the heater-power slider is
active. Switching back to closed-loop resets power_soll to 0 and
re-enables the TC.
On play, the mode is automatically reset to closed-loop.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Shows remaining time for the current step and the entire sud in large
accent-coloured monospace text to the right of the transport buttons,
matching the per-step plate colour. Hidden until a step is active.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX
Remove one-time sync guards (initialPowerSync, initialSpeedSync) so heater
power and stirrer speed sliders track the server's ist readbacks on every
push, not just on first connect. Also remove the zero-out on sud
stop/pause: with continuous ist tracking the animation and slider always
agree, and the zero-out caused them to diverge when the server kept sending
non-zero values during a pause.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SH2D4LRuCnhLSzoYerAfJX