Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1184ef63 | ||
|
|
7bf475f257 | ||
|
|
92b9bdc1ba | ||
|
|
4d59e11c1e | ||
|
|
5a874e3869 | ||
|
|
0171cb20c7 | ||
|
|
c60fdff74d | ||
|
|
1cd2f64b43 | ||
|
|
af4e7ea471 | ||
|
|
9f262c48d0 | ||
|
|
280f74edaa | ||
|
|
c750af922a | ||
|
|
ce5eb3bc23 | ||
|
|
a1864a5257 | ||
|
|
6c746c8964 | ||
|
|
71b1010b27 | ||
|
|
a19d2504fb | ||
|
|
d19f1066d1 | ||
|
|
07c3f0a7d1 | ||
|
|
3b34f52bb0 | ||
|
|
6d1429daca | ||
|
|
57677d7881 | ||
|
|
d1e556024d | ||
|
|
1e7042d906 |
@@ -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.
|
||||
@@ -771,10 +771,27 @@ session - don't assume they're a matched pair unless you've just watched a
|
||||
run finish (`log_latest_sud.json` is only ever current *immediately* after
|
||||
a `Stop`/`DONE`).
|
||||
|
||||
`server/brewpi.py` also mirrors everything it prints (every component's
|
||||
`print()`-based status/debug output) to a plain text log,
|
||||
`logs/brewpi.<timestamp>.log`, alongside those JSON logs - useful for
|
||||
post-mortems without needing to have been watching the console live.
|
||||
`server/brewpi.py` also mirrors console output to a plain text log,
|
||||
`logs/brewpi.<timestamp>.log` (plus an always-truncated `logs/brewpi.latest.log`
|
||||
for tail-style consumers), alongside those JSON logs - useful for post-mortems
|
||||
without needing to have been watching the console live. Every task/component
|
||||
logs through the stdlib `logging` module rather than `print()`: `AttributeChange`
|
||||
(`utils/value.py`, the common base of every `ATask` and component ABC) gives
|
||||
each one a `self.log = logging.getLogger(type(self).__name__)`, so messages
|
||||
are automatically tagged with the component's class name with no hand-typing
|
||||
needed. `server/brewpi.py`'s `Tee` class mirrors `stdout`/`stderr` to both log
|
||||
files and stamps each line with a `<date>T<time>:` prefix; `logging.basicConfig()`
|
||||
uses a bare `%(name)s:%(message)s` formatter, so the combined line reads
|
||||
`<date>T<time>:<component>:<message>` without double-stamping (and third-party
|
||||
loggers like `websockets`/`asyncio` get the same formatting for free).
|
||||
`components/sud.py`'s `Sud` logs its own lifecycle (load/start/pause/continue/
|
||||
stop, each step's `"<descr>(<number>) Heating"`/`"Reached"` transitions,
|
||||
waiting for user interaction, finished), and `HeaterTask`/`StirrerTask` log
|
||||
connected/disconnected state. `SudForecastEstimator` drives its own throwaway
|
||||
`Sud` through an entire schedule with no real waiting to predict duration, so
|
||||
it's given its own `"SudForecastEstimator"` logger (silenced to `WARNING` by
|
||||
default in `components/sud_forecast.py`) rather than sharing `Sud`'s, keeping
|
||||
an instant internal forecast run out of the log next to actual brew activity.
|
||||
|
||||
Both JSON logs also carry `dt`, the effective `SimWarpFactor` (already
|
||||
clamped to `1.0` for real hardware - see above), `Config` (the whole
|
||||
|
||||
+133
-52
@@ -422,7 +422,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.sud_empty = True
|
||||
self.sud_state = None
|
||||
self.sud_user_message = None
|
||||
self._confirm_box = None
|
||||
self.user_confirm_pending = False
|
||||
# Global, not Sud-specific - from the 'System' channel's one-time
|
||||
# startup message. warp_factor defaults to 1.0 (no scaling) until
|
||||
# that arrives, or for a server that doesn't send it at all.
|
||||
@@ -449,9 +449,15 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.sud_elapsed_last = None
|
||||
self.sud_name = ''
|
||||
self.sud_schedule = []
|
||||
# Hardware baseline from config.json's Pot section (see
|
||||
# on_system_changed()) - passed to Sud._parse_data() so a loaded
|
||||
# sud.json that doesn't override 'mass' still resolves to the real
|
||||
# kettle mass instead of _parse_data()'s own bare 0 default.
|
||||
self.pot_config = {}
|
||||
self.sud_pot_mass = 0
|
||||
self.sud_grain_mass = 0
|
||||
self.sud_water_mass = 0
|
||||
self.sud_volumen = 0
|
||||
self.sud_step_index = None
|
||||
self.sud_step_descr = None
|
||||
self.sud_step_type = None
|
||||
@@ -539,6 +545,24 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.cooling_blink_timer = QtCore.QTimer(self)
|
||||
self.cooling_blink_timer.timeout.connect(self.on_cooling_blink)
|
||||
|
||||
# Persistent user-confirm control (a Sud step's WAIT_USER, e.g.
|
||||
# "Bitte Malz einfuellen und bestaetigen") - lives in the top
|
||||
# toolbar rather than a transient dialog, so it's visible
|
||||
# regardless of which tab is active. Flashes yellow with the
|
||||
# step's message while pending (same blink-timer pattern as
|
||||
# status_label_cooling above); disabled and blank otherwise.
|
||||
# set_user_confirm_pending() also disables the rest of the GUI
|
||||
# while pending, forcing this button to be dealt with first.
|
||||
self.toolBar.addSeparator()
|
||||
self.label_user_confirm = QtWidgets.QLabel()
|
||||
self.toolBar.addWidget(self.label_user_confirm)
|
||||
self.btn_user_confirm = QtWidgets.QPushButton("Confirm")
|
||||
self.btn_user_confirm.setEnabled(False)
|
||||
self.btn_user_confirm.clicked.connect(self.on_action_user_confirm)
|
||||
self.toolBar.addWidget(self.btn_user_confirm)
|
||||
self.user_confirm_blink_timer = QtCore.QTimer(self)
|
||||
self.user_confirm_blink_timer.timeout.connect(self.on_user_confirm_blink)
|
||||
|
||||
self.update_sud_actions()
|
||||
self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
|
||||
self.actionStart.triggered.connect(self.on_action_sud_start)
|
||||
@@ -721,7 +745,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.sud_empty = True
|
||||
self.sud_state = None
|
||||
self.sud_user_message = None
|
||||
self._close_confirm_dialog()
|
||||
self.set_user_confirm_pending(False)
|
||||
for w in (self.Slider_temp_soll, self.doubleSpinBox_heatrate_soll,
|
||||
self.Slider_pwr_soll, self.Slider_speed_soll):
|
||||
w.setEnabled(True)
|
||||
@@ -740,6 +764,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.sud_pot_mass = 0
|
||||
self.sud_grain_mass = 0
|
||||
self.sud_water_mass = 0
|
||||
self.sud_volumen = 0
|
||||
self.sud_step_index = None
|
||||
self.sud_step_descr = None
|
||||
self.sud_step_type = None
|
||||
@@ -780,6 +805,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.btn_pot_reset.setVisible(msg['PlantSim'])
|
||||
self.label_pot_temp.setVisible(msg['PlantSim'])
|
||||
self.doubleSpinBox_pot_temp.setVisible(msg['PlantSim'])
|
||||
elif "Pot" in key:
|
||||
self.pot_config = msg['Pot']
|
||||
if self.sud_empty:
|
||||
self.update_status_step_label()
|
||||
self.update_status_env_label()
|
||||
|
||||
def on_action_pot_reset(self):
|
||||
@@ -849,24 +878,47 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
def on_action_sud_stop(self):
|
||||
self.msg_sud.send({'Stop': True})
|
||||
|
||||
def show_user_message(self, message):
|
||||
if self._confirm_box is not None:
|
||||
def set_user_confirm_pending(self, pending, message=''):
|
||||
if pending == self.user_confirm_pending:
|
||||
return
|
||||
dlg = QtWidgets.QDialog(self)
|
||||
dlg.setWindowTitle("Sud")
|
||||
dlg.setWindowModality(QtCore.Qt.NonModal)
|
||||
layout = QtWidgets.QVBoxLayout(dlg)
|
||||
layout.addWidget(QtWidgets.QLabel(message))
|
||||
btn = QtWidgets.QPushButton("OK")
|
||||
btn.clicked.connect(lambda: self.msg_sud.send({'Confirm': True}))
|
||||
layout.addWidget(btn)
|
||||
self._confirm_box = dlg
|
||||
dlg.show()
|
||||
self.user_confirm_pending = pending
|
||||
if pending:
|
||||
self.label_user_confirm.setText(message)
|
||||
self.btn_user_confirm.setEnabled(True)
|
||||
self.btn_user_confirm.setStyleSheet("background-color: yellow; font-weight: bold;")
|
||||
self.user_confirm_blink_timer.start(500)
|
||||
# Modally disabled: nothing else in the GUI should be usable
|
||||
# until the pending step is actually confirmed - centralwidget
|
||||
# covers every tab's content; the toolbar/menu actions are
|
||||
# disabled individually (rather than disabling self.toolBar
|
||||
# itself) so btn_user_confirm, a sibling widget on the same
|
||||
# toolbar, stays enabled.
|
||||
self.centralwidget.setEnabled(False)
|
||||
for action in (self.actionStart, self.actionPause, self.actionStop,
|
||||
self.actionSudNew, self.actionSudSave, self.actionSudLoad):
|
||||
action.setEnabled(False)
|
||||
else:
|
||||
self.user_confirm_blink_timer.stop()
|
||||
self.btn_user_confirm.setStyleSheet("")
|
||||
self.btn_user_confirm.setEnabled(False)
|
||||
self.label_user_confirm.setText('')
|
||||
self.centralwidget.setEnabled(True)
|
||||
self.actionSudNew.setEnabled(True)
|
||||
self.actionSudSave.setEnabled(True)
|
||||
self.actionSudLoad.setEnabled(True)
|
||||
# Start/Pause/Stop depend on sud_state/sud_empty, not just
|
||||
# "was disabled for confirm" - let the usual logic pick the
|
||||
# right one back up rather than assuming all three should
|
||||
# simply return to enabled.
|
||||
self.update_sud_actions()
|
||||
|
||||
def _close_confirm_dialog(self):
|
||||
if self._confirm_box is not None:
|
||||
self._confirm_box.close()
|
||||
self._confirm_box = None
|
||||
def on_user_confirm_blink(self):
|
||||
yellow = self.btn_user_confirm.styleSheet() == ""
|
||||
self.btn_user_confirm.setStyleSheet(
|
||||
"background-color: yellow; font-weight: bold;" if yellow else "")
|
||||
|
||||
def on_action_user_confirm(self):
|
||||
self.msg_sud.send({'Confirm': True})
|
||||
|
||||
def on_action_sud_new(self):
|
||||
# Sends an empty schedule via the same Load path Load Sud uses, with
|
||||
@@ -1002,7 +1054,22 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
# Receiving anything at all on this channel means a Sud is loaded
|
||||
# server-side - the schedule it's running may still be coming.
|
||||
self.sud_loaded = True
|
||||
for key in msg:
|
||||
# UserMessage must be processed before State: a step's user_message
|
||||
# is set once, when the step starts (components/sud.py's
|
||||
# _advance()), well before state later flips to WAIT_USER
|
||||
# (_finish_step()) - so UserMessage's key was inserted into the
|
||||
# server's global_state earlier in its lifetime and would precede
|
||||
# State there. A client connecting fresh while a step is already
|
||||
# WAIT_USER gets both bundled into this same replay dict; processing
|
||||
# State first would check self.sud_user_message before this very
|
||||
# message has set it, silently failing set_user_confirm_pending()'s
|
||||
# guard and leaving the Confirm button looking merely disabled
|
||||
# instead of pending - see web/app.js's onSudChanged() for the twin
|
||||
# fix (same server, same race).
|
||||
ordered_keys = ['UserMessage'] + [k for k in msg if k != 'UserMessage']
|
||||
for key in ordered_keys:
|
||||
if key not in msg:
|
||||
continue
|
||||
if "Json" in key:
|
||||
if self.sud_save_path:
|
||||
with open(self.sud_save_path, 'w') as f:
|
||||
@@ -1067,9 +1134,9 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.forecast_plot.show_computing(self.sud_name)
|
||||
|
||||
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
|
||||
self.show_user_message(self.sud_user_message)
|
||||
self.set_user_confirm_pending(True, self.sud_user_message)
|
||||
elif prev_state == SUD_WAIT_USER_STATE and self.sud_state != SUD_WAIT_USER_STATE:
|
||||
self._close_confirm_dialog()
|
||||
self.set_user_confirm_pending(False)
|
||||
self._update_step_plates()
|
||||
elif "UserMessage" in key:
|
||||
self.sud_user_message = msg['UserMessage']
|
||||
@@ -1159,42 +1226,50 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self._update_step_plates()
|
||||
|
||||
def update_status_step_label(self):
|
||||
if self.sud_step_descr:
|
||||
# +1: sud_step_index is the 0-based index Sud.index uses, but
|
||||
# StepPlate (the Progress tab) labels steps 1-based - match it
|
||||
# here so the status bar doesn't announce a step one number
|
||||
# below what the Progress tab shows for the same step.
|
||||
text = "Step {}: {}".format(self.sud_step_index + 1, self.sud_step_descr)
|
||||
if self.sud_step_type == 'hold':
|
||||
remaining = max(self.sud_hold_remaining, 0.0)
|
||||
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
|
||||
# sud_step_index is the same 0-based index Sud.index uses into
|
||||
# its own schedule list (see components/sud.py) - self.
|
||||
# sud_schedule is built from the very same doc, so it lines up
|
||||
# directly; grain_mass/water_mass are already resolved against
|
||||
# default.step there (see components/sud.py's _build_step()).
|
||||
if 0 <= self.sud_step_index < len(self.sud_schedule):
|
||||
step = self.sud_schedule[self.sud_step_index]
|
||||
step_pot = step.get('pot', {})
|
||||
grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0)
|
||||
else:
|
||||
grain, water = self.sud_grain_mass, self.sud_water_mass
|
||||
elif self.sud_schedule:
|
||||
# Loaded but not started yet (no real Step message has
|
||||
text = ""
|
||||
# Before any schedule is loaded, fall back to config.json's Pot
|
||||
# section (self.pot_config, captured in on_system_changed()) so the
|
||||
# status bar already shows pot mass/water/volume/energy as soon as
|
||||
# the server connects, rather than a blank status bar until Load -
|
||||
# mirrors web/app.js's updateStatusLine(). Step description/
|
||||
# remaining-time only exist once a schedule is actually loaded, so
|
||||
# they stay inside the "not empty" branch below.
|
||||
if self.sud_empty:
|
||||
pot = self.pot_config.get('mass', 0)
|
||||
water = self.pot_config.get('water_mass', 0)
|
||||
volumen = self.pot_config.get('volumen', 0)
|
||||
grain = 0
|
||||
else:
|
||||
pot = self.sud_pot_mass
|
||||
volumen = self.sud_volumen
|
||||
grain, water = self.sud_grain_mass, self.sud_water_mass
|
||||
if self.sud_step_descr:
|
||||
# +1: sud_step_index is the 0-based index Sud.index uses, but
|
||||
# StepPlate (the Progress tab) labels steps 1-based - match it
|
||||
# here so the status bar doesn't announce a step one number
|
||||
# below what the Progress tab shows for the same step.
|
||||
text = "Step {}: {}".format(self.sud_step_index + 1, self.sud_step_descr)
|
||||
if self.sud_step_type == 'hold':
|
||||
remaining = max(self.sud_hold_remaining, 0.0)
|
||||
text += " ({:.0f}:{:02.0f} remaining)".format(remaining // 60, remaining % 60)
|
||||
# sud_step_index is the same 0-based index Sud.index uses into
|
||||
# its own schedule list (see components/sud.py) - self.
|
||||
# sud_schedule is built from the very same doc, so it lines up
|
||||
# directly; grain_mass/water_mass are already resolved against
|
||||
# default.step there (see components/sud.py's _build_step()).
|
||||
if 0 <= self.sud_step_index < len(self.sud_schedule):
|
||||
step = self.sud_schedule[self.sud_step_index]
|
||||
step_pot = step.get('pot', {})
|
||||
grain, water = step_pot.get('grain_mass', 0), step_pot.get('water_mass', 0)
|
||||
# else: loaded but not started yet (no real Step message has
|
||||
# arrived) - preview the doc's own initial grain_mass/
|
||||
# water_mass right away instead of leaving the status line
|
||||
# blank until Start, mirroring tasks/sud.py's SudTask.
|
||||
# apply_plant_params() applying it to the real plant/
|
||||
# controller immediately on Load too.
|
||||
text = ""
|
||||
grain, water = self.sud_grain_mass, self.sud_water_mass
|
||||
else:
|
||||
self.statusBar().clearMessage()
|
||||
return
|
||||
|
||||
pot = self.sud_pot_mass
|
||||
mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg".format(
|
||||
pot, water, grain, pot + water + grain)
|
||||
mass_text = "Pot: {:.2f} kg, Water: {:.2f} kg, Grain {:.2f} kg, total {:.2f} kg, Volume {:.1f} L".format(
|
||||
pot, water, grain, pot + water + grain, volumen)
|
||||
text = text + " " + mass_text if text else mass_text
|
||||
# Sums, not just the active step's own figures: total process time
|
||||
# is just self.sud_elapsed_last (Sud's own tick-counted total -
|
||||
@@ -1215,7 +1290,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
|
||||
def update_sud_forecast(self, doc):
|
||||
try:
|
||||
name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc)
|
||||
name, _, schedule, pot_mass, _, _, _, grain_mass, water_mass = Sud._parse_data(doc, self.pot_config)
|
||||
except (KeyError, TypeError):
|
||||
return
|
||||
# A genuinely different schedule (an actual Load, or "New Sud")
|
||||
@@ -1244,6 +1319,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||
self.sud_pot_mass = pot_mass
|
||||
self.sud_grain_mass = grain_mass
|
||||
self.sud_water_mass = water_mass
|
||||
# Sud._parse_data() has no notion of 'volumen' (unlike mass/L/Td,
|
||||
# nothing downstream needs it for the actual physics) - read it
|
||||
# straight off the doc, falling back to config.json's Pot.volumen
|
||||
# same as _parse_data() does for mass, mirroring web/app.js's
|
||||
# parseSudDoc().
|
||||
self.sud_volumen = doc.get('pot', {}).get('volumen', self.pot_config.get('volumen', 0))
|
||||
self.sud_empty = len(schedule) == 0
|
||||
if is_new_schedule:
|
||||
self.sud_step_descr = None
|
||||
|
||||
@@ -45,7 +45,7 @@ class HeaterHendi(AHeater):
|
||||
# tick loop (with self.device.open(): activate(True)/(False)) and
|
||||
# from a client's Connect/Disconnect command, neither of which
|
||||
# expect activate() itself to ever raise.
|
||||
print(f"HeaterHendi: comm error in activate({enable}): {e}")
|
||||
self.log.error(f"comm error in activate({enable}): {e}")
|
||||
self.disconnect()
|
||||
|
||||
def is_activated(self):
|
||||
@@ -55,7 +55,7 @@ class HeaterHendi(AHeater):
|
||||
s = self.hendi.isRemoteEnable()
|
||||
return '1' in s
|
||||
except Exception as e:
|
||||
print(f"HeaterHendi: comm error in is_activated: {e}")
|
||||
self.log.error(f"comm error in is_activated: {e}")
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
@@ -72,7 +72,7 @@ class HeaterHendi(AHeater):
|
||||
self.hendi.setPowerWatts(power)
|
||||
self.power_eff = self.hendi.getPowerWatts() if power > 0 else 0
|
||||
except Exception as e:
|
||||
print(f"HeaterHendi: comm error in process: {e}")
|
||||
self.log.error(f"comm error in process: {e}")
|
||||
self.disconnect()
|
||||
self.power_eff = 0
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import logging
|
||||
import time
|
||||
import serial
|
||||
import sys
|
||||
@@ -11,6 +12,7 @@ class HendiException(Exception):
|
||||
|
||||
class HendiCtrl:
|
||||
def __init__(self, port, speed, debug=False):
|
||||
self.log = logging.getLogger(type(self).__name__)
|
||||
self.debug = debug
|
||||
self.prompt = b':'
|
||||
self.sw_id = None
|
||||
@@ -48,7 +50,7 @@ class HendiCtrl:
|
||||
try:
|
||||
self.ser.open()
|
||||
except serial.SerialException as e:
|
||||
print(f"HendiCtrl: connect failed: {e}")
|
||||
self.log.error(f"connect failed: {e}")
|
||||
return False
|
||||
# The port itself is what "connected" means - a failed version query
|
||||
# (e.g. the device sitting in its ungraceful-disconnect lockout, see
|
||||
@@ -57,9 +59,9 @@ class HendiCtrl:
|
||||
try:
|
||||
self.sw_id = self.getSoftwareIdentifier()
|
||||
self.sw_ver = self.getSoftwareVersion()
|
||||
print(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
|
||||
self.log.info(f"{self.sw_id}, F/W-Version: {self.sw_ver}")
|
||||
except HendiException:
|
||||
print("HendiCtrl: Hendi not found")
|
||||
self.log.warning("Hendi not found")
|
||||
return True
|
||||
|
||||
@contextmanager
|
||||
@@ -91,7 +93,7 @@ class HendiCtrl:
|
||||
self.ser.rts = True
|
||||
|
||||
def firmware_update(self, filename):
|
||||
print("Start firmware update")
|
||||
self.log.info("Start firmware update")
|
||||
self.enter_bootloader()
|
||||
self.ser.readline()
|
||||
|
||||
@@ -122,7 +124,7 @@ class HendiCtrl:
|
||||
self.ser.flushInput()
|
||||
data = s.encode()
|
||||
if self.debug:
|
||||
print(f"HendiCtrl: -> {data!r}")
|
||||
self.log.debug(f"-> {data!r}")
|
||||
self.ser.write(data + b'\r')
|
||||
|
||||
def __read(self):
|
||||
@@ -132,7 +134,7 @@ class HendiCtrl:
|
||||
# Read answer line
|
||||
answer_raw = self.ser.readline()
|
||||
if self.debug:
|
||||
print(f"HendiCtrl: <- echo={echo!r} answer={answer_raw!r}")
|
||||
self.log.debug(f"<- echo={echo!r} answer={answer_raw!r}")
|
||||
answer = answer_raw.decode('utf-8').replace('\r', '').replace('\n', '')
|
||||
if ':' in answer:
|
||||
result = answer.split(':')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
import serial
|
||||
import enum
|
||||
@@ -48,8 +49,27 @@ class Varid(enum.Enum):
|
||||
MOTOR_CURRENT_LIMIT_OCCURRENCE_COUNT = 45
|
||||
|
||||
|
||||
# Bit flags of the STATUS variable (Varid.STATUS). A non-zero
|
||||
# SAFE_START_VIOLATION is the "fail-safe" state: the controller accepted
|
||||
# the GO command but still refuses to spin the motor. The other bits are
|
||||
# the SMC's other error conditions, which also stop the motor and can
|
||||
# make GO fail to bring it back.
|
||||
class StatusError(enum.IntFlag):
|
||||
SAFE_START_VIOLATION = 0x0001
|
||||
REQUIRED_CHANNEL_INVALID = 0x0002
|
||||
SERIAL_ERROR = 0x0004
|
||||
COMMAND_TIMEOUT = 0x0008
|
||||
LIMIT_KILL_SWITCH = 0x0010
|
||||
LOW_VIN = 0x0020
|
||||
HIGH_VIN = 0x0040
|
||||
OVER_TEMPERATURE = 0x0080
|
||||
MOTOR_DRIVER_ERROR = 0x0100
|
||||
ERR_LINE_HIGH = 0x0200
|
||||
|
||||
|
||||
class Pololu1376:
|
||||
def __init__(self, serial_port):
|
||||
self.log = logging.getLogger(type(self).__name__)
|
||||
# Deferred-open pyserial idiom: serial_port is constructed with no
|
||||
# port set (see components/actor/stirrerpololu1376.py), so it isn't
|
||||
# actually opened until connect() is called - a missing/unplugged
|
||||
@@ -70,7 +90,7 @@ class Pololu1376:
|
||||
self.ser.close()
|
||||
self.ser.open()
|
||||
except serial.SerialException as e:
|
||||
print(f"Pololu1376: connect failed: {e}")
|
||||
self.log.error(f"connect failed: {e}")
|
||||
return False
|
||||
# set_output_flow_control() requires the port to already be open -
|
||||
# can only happen here, after open() above, not at construction time.
|
||||
@@ -78,10 +98,10 @@ class Pololu1376:
|
||||
try:
|
||||
self.stop()
|
||||
self.firmware_version = self.get_firmware_version()
|
||||
print("Pololu1376 F/W-Version:", self.firmware_version)
|
||||
self.log.info("F/W-Version: {}".format(self.firmware_version))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Pololu1376: connect failed: {e}")
|
||||
self.log.error(f"connect failed: {e}")
|
||||
self.ser.close()
|
||||
self.firmware_version = None
|
||||
return False
|
||||
@@ -117,8 +137,15 @@ class Pololu1376:
|
||||
|
||||
return rsp[1:]
|
||||
|
||||
def get_status_errors(self):
|
||||
return StatusError(self.get_variable(Varid.STATUS))
|
||||
|
||||
def go(self):
|
||||
return self.cmd("GO")
|
||||
rsp = self.cmd("GO")
|
||||
errors = self.get_status_errors()
|
||||
if errors:
|
||||
self.log.error(f"still in fail-safe after GO, active errors: {errors!r}")
|
||||
return rsp
|
||||
|
||||
def stop(self):
|
||||
return self.cmd("X")
|
||||
|
||||
@@ -46,7 +46,7 @@ class StirrerPololu1376(AStirrer):
|
||||
if not self.connected:
|
||||
return
|
||||
try:
|
||||
print("activate {}".format(enable))
|
||||
self.log.debug("activate {}".format(enable))
|
||||
if enable:
|
||||
self.drv.go()
|
||||
else:
|
||||
@@ -58,7 +58,7 @@ class StirrerPololu1376(AStirrer):
|
||||
# loop (with self.device.open(): activate(True)/(False)) and
|
||||
# from a client's Connect/Disconnect command, neither of which
|
||||
# expect activate() itself to ever raise.
|
||||
print(f"StirrerPololu1376: comm error in activate({enable}): {e}")
|
||||
self.log.error(f"comm error in activate({enable}): {e}")
|
||||
self.disconnect()
|
||||
|
||||
def _on_set_speed(self, speed):
|
||||
@@ -66,23 +66,23 @@ class StirrerPololu1376(AStirrer):
|
||||
return
|
||||
try:
|
||||
self.drv.motor_forward(speed)
|
||||
print("Set speed to {} %".format(speed))
|
||||
self.log.debug("Set speed to {} %".format(speed))
|
||||
except Exception as e:
|
||||
print(f"StirrerPololu1376: comm error in _on_set_speed: {e}")
|
||||
self.log.error(f"comm error in _on_set_speed: {e}")
|
||||
self.disconnect()
|
||||
|
||||
def _on_process(self):
|
||||
if not self.connected:
|
||||
return
|
||||
try:
|
||||
status = self.drv.get_variable(Varid.STATUS)
|
||||
limit_status = self.drv.get_variable(Varid.STATUS_LIMIT_STATUS)
|
||||
if status & 0x01 == 0x01:
|
||||
if limit_status & 0x01 == 0x01:
|
||||
print("Recover after motor error!")
|
||||
errors = self.drv.get_status_errors()
|
||||
if errors:
|
||||
self.log.warning(f"motor controller reports errors: {errors!r}")
|
||||
if StatusError.SAFE_START_VIOLATION in errors:
|
||||
self.log.warning("Recover after safe start violation!")
|
||||
self.drv.go()
|
||||
except Exception as e:
|
||||
print(f"StirrerPololu1376: comm error in _on_process: {e}")
|
||||
self.log.error(f"comm error in _on_process: {e}")
|
||||
self.disconnect()
|
||||
|
||||
|
||||
|
||||
+43
-32
@@ -32,40 +32,51 @@ git history for `temp_controller.py`/`temp_controller_smith.py`).
|
||||
hold_scale)` — the overshoot-compensation scheduling logic now lives
|
||||
entirely in the temp controller, not the generic `Pid` block.
|
||||
|
||||
- [ ] **No automated tests for the heat-rate filtering or Smith
|
||||
correction specifically.** An earlier `tests/components/pid/` suite
|
||||
(stdlib `unittest`, no pytest) covering FSM transitions, anti-windup
|
||||
clamping, and Kalman convergence was removed before the Kalman-filter
|
||||
removal/Smith rewrite. A new `tests/components/pid/` suite was added
|
||||
alongside the HOLD-windup fix below (`test_pid.py`,
|
||||
`test_temp_controller_closed_loop.py`) covering the `yi_max` clamp and
|
||||
closed-loop disturbance/ramp/transition behavior, but
|
||||
`_compute_heatrate()`'s backward-difference + low-pass filtering and
|
||||
the two-model Smith correction itself still have no dedicated
|
||||
coverage. This drives a physical heater — worth extending.
|
||||
- [x] **No automated tests for the heat-rate filtering or Smith
|
||||
correction specifically.** Fixed: `tests/components/pid/test_heatrate_filter.py`
|
||||
covers `_compute_heatrate()` - a first-tick-reads-zero regression test
|
||||
(verified it would catch the old hardcoded `last_theta_ist = 20` bug),
|
||||
convergence to the true rate on a steady ramp (heating and cooling),
|
||||
and the `beta` pre-filter measurably suppressing noise amplification vs.
|
||||
raw differentiation. `tests/components/pid/test_temp_controller_smith_correction.py`
|
||||
covers the two-model correction in `temp_controller_smith.py`: with a
|
||||
matched internal model, `theta_ist` stays close to the fast (zero-delay)
|
||||
model's own prediction (contrasted against a deliberately mismatched
|
||||
model, which diverges 10x+ further); and the corrected estimate visibly
|
||||
rises before the real transport delay (`Td`) has elapsed, while the raw
|
||||
plant reading is still flat.
|
||||
|
||||
- [ ] **`set_model_power` isn't defined on every controller, but
|
||||
`brewpi.py` wires it unconditionally.** `brewpi.py` always does
|
||||
`heater.set_on_changed("power_set", tc.set_model_power)` regardless
|
||||
of `Controller.pid_type`. Only `temp_controller_smith.py` (the Smith
|
||||
predictor) defines `set_model_power`; `temp_controller.py` (`"Normal"`)
|
||||
does not, so starting the server with `"pid_type": "Normal"` crashes
|
||||
at wiring time with `AttributeError: 'TempController' object has no
|
||||
attribute 'set_model_power'`. Either add a no-op `set_model_power` to
|
||||
`TempControllerBase`, or only wire it when the configured controller
|
||||
actually exposes a model to feed.
|
||||
- [x] **`set_model_power` isn't defined on every controller, but
|
||||
`brewpi.py` wires it unconditionally.** Fixed: `TempControllerBase`
|
||||
now has a no-op `set_model_power`/`set_ambient_temperature`/
|
||||
`set_model_plant_params` (`temp_controller_base.py:83-90`), so
|
||||
`brewpi.py`'s unconditional `heater.set_on_changed("power_set",
|
||||
tc.set_model_power)` no longer crashes for `"pid_type": "Normal"` -
|
||||
`temp_controller_smith.py` still overrides `set_model_power` to
|
||||
actually feed its internal model. The redundant `hasattr()` guards
|
||||
at both call sites (`brewpi.py`, `components/sud_forecast.py`) were
|
||||
removed along with it.
|
||||
|
||||
- [ ] **Config access is unchecked `dict[key]` everywhere.**
|
||||
`params['Hold']`, `params['Td']`, etc., throughout, with no schema
|
||||
validation at load time. We already hit this bug class once
|
||||
(`config.json.sim`'s `gain`/`Model` nesting mismatch). A small
|
||||
schema/dataclass validation layer at config-load would surface
|
||||
errors immediately instead of mid-`__init__` — and would have caught
|
||||
the dead `TempCtrl.Kalman` / `Model.kn` / `Plant.kn` keys that used
|
||||
to sit unused in `config-sim.json.tpl`/`.sim` (since deleted), and the
|
||||
`Model.gain`/`Plant.gain` keys that did the same in `config.json.sim`
|
||||
before that file was removed entirely — `Pot` dropped `gain`
|
||||
entirely, see `components/plant/TODO.md`.
|
||||
- [x] **Config access is unchecked `dict[key]` everywhere.** Partially
|
||||
fixed, scoped to the section that actually caused an incident
|
||||
(`config.json.sim`'s `gain`/`Model` nesting mismatch): `utils/
|
||||
config_validate.py`'s `require()` is a small stdlib-only dotted-path
|
||||
checker (no schema library added - matches this project's no-pytest/
|
||||
stdlib-first preference), and `validate_temp_ctrl()` requires
|
||||
`TempCtrl.pid_type` plus every `kp`/`ki`/`kd`/`kt` gain under
|
||||
`TempCtrl.Outer` and `TempCtrl.Inner.{Heat,Hold,Cool}` - the ones every
|
||||
`pid_type` reads unconditionally. Called once in `server/brewpi.py`
|
||||
right after `json.load()`, before any factory runs, so a missing/
|
||||
misnamed gain now fails fast with `ConfigError: missing required
|
||||
config key: TempCtrl.Inner.Hold.kp` instead of a bare `KeyError` from
|
||||
inside some component's `__init__` or first `process()` tick. Covered
|
||||
by `tests/utils/test_config_validate.py`.
|
||||
Deliberately left for later, since neither has actually crashed
|
||||
anything yet: `Heater`/`Stirrer`/`TempSensor`/`Pot` config sections
|
||||
(all already default gracefully via `.get(..., default)` in
|
||||
`PlantFactory` etc.), and erroring (vs. just `log.warning()`) on
|
||||
unknown/stale keys like the old `TempCtrl.Kalman`/`Model.kn`/
|
||||
`Plant.kn`/`Model.gain` leftovers - see `components/plant/TODO.md`.
|
||||
|
||||
- [x] **`pid_heat` can wind up during a `HOLD`-state disturbance with no
|
||||
anti-windup engagement.** A cold-water disturbance while holding drove
|
||||
|
||||
@@ -25,7 +25,7 @@ class TempSensor_max31865(ATemperatureSensor):
|
||||
# temperature()'s own except handler already retries via
|
||||
# reopen() on the next tick, same recovery path as a later
|
||||
# runtime failure.
|
||||
print(f"TempSensor_max31865: could not open SPI at startup: {e}")
|
||||
self.log.error(f"could not open SPI at startup: {e}")
|
||||
|
||||
def _open_spi(self):
|
||||
self.spi.open(0, 0)
|
||||
@@ -56,7 +56,7 @@ class TempSensor_max31865(ATemperatureSensor):
|
||||
try:
|
||||
self._open_spi()
|
||||
except Exception as e:
|
||||
print(f"TempSensor_max31865: reopen failed: {e}")
|
||||
self.log.error(f"reopen failed: {e}")
|
||||
|
||||
def read_reg(self, addr):
|
||||
reg = self.spi.xfer([addr, 0xFF])
|
||||
@@ -98,7 +98,7 @@ class TempSensor_max31865(ATemperatureSensor):
|
||||
# nothing restarts a dead ATask). reopen() gives the SPI bus a
|
||||
# chance to recover from a wedged state without a full process/
|
||||
# Pi reboot - see docs/pi_deployment_notes.md.
|
||||
print(f"TempSensor_max31865: comm error reading sensor, reopening SPI: {e}")
|
||||
self.log.error(f"comm error reading sensor, reopening SPI: {e}")
|
||||
self.reopen()
|
||||
return self.temp
|
||||
|
||||
|
||||
+29
-2
@@ -140,12 +140,30 @@ class Sud(AttributeChange):
|
||||
a schedule does so explicitly itself, e.g. by writing save()'s
|
||||
result to one of the sude/*.json files), resetting to IDLE as if
|
||||
freshly loaded. Refused while a brew is in progress, or if data is
|
||||
malformed - in either case the current schedule is left untouched."""
|
||||
malformed - in either case the current schedule is left untouched.
|
||||
|
||||
validate_sud() catches the same malformed-document cases _parse_data()
|
||||
itself would raise on, plus content _parse_data() happily resolves
|
||||
but that would crash much later - a step setting its own 'temperature'
|
||||
with no ramp.rate anywhere to inherit only blows up when tasks/sud.py
|
||||
or SudForecastEstimator actually reaches that step (ramp['rate'] has
|
||||
no .get() fallback), which for the forecast estimator can be as soon
|
||||
as this very Load. Refusing it here instead, with the reason logged
|
||||
(nothing else here logs *why* a load was refused), is strictly better
|
||||
than a bare KeyError from inside a run or a worker-thread forecast."""
|
||||
# Imported here, not at module level: utils/sud_validate.py imports
|
||||
# Sud from this very module to reuse _parse_data(), so a top-level
|
||||
# import here would be circular.
|
||||
from utils.sud_validate import validate_sud
|
||||
from utils.config_validate import ConfigError
|
||||
|
||||
if self.state not in (SudState.IDLE, SudState.DONE):
|
||||
return False
|
||||
try:
|
||||
validate_sud(data)
|
||||
parsed = self._parse_data(data, self._pot_config)
|
||||
except (KeyError, TypeError):
|
||||
except (KeyError, TypeError, ConfigError) as e:
|
||||
self.log.warning("Refusing to load malformed sud document: {}".format(e))
|
||||
return False
|
||||
|
||||
(self.name, self.description, self.schedule, self.pot_mass,
|
||||
@@ -154,6 +172,7 @@ class Sud(AttributeChange):
|
||||
self._data = data
|
||||
|
||||
self._reset_run_state()
|
||||
self.log.info("Loaded '{}'".format(self.name))
|
||||
return True
|
||||
|
||||
def derive_plant_params(self, grain_mass, water_mass):
|
||||
@@ -183,11 +202,13 @@ class Sud(AttributeChange):
|
||||
if self.state == SudState.PAUSED:
|
||||
self.state = self._paused_from
|
||||
self._paused_from = None
|
||||
self.log.info("Continued")
|
||||
return
|
||||
if self.state not in (SudState.IDLE, SudState.DONE):
|
||||
return
|
||||
self.index = -1
|
||||
self.elapsed = 0.0
|
||||
self.log.info("Started")
|
||||
self._advance()
|
||||
|
||||
def pause(self):
|
||||
@@ -196,11 +217,13 @@ class Sud(AttributeChange):
|
||||
if self.state in (SudState.RAMPING, SudState.HOLDING):
|
||||
self._paused_from = self.state
|
||||
self.state = SudState.PAUSED
|
||||
self.log.info("Paused")
|
||||
|
||||
def stop(self):
|
||||
"""Aborts the run, discarding progress back to IDLE."""
|
||||
if self.state not in (SudState.IDLE, SudState.DONE):
|
||||
self._reset_run_state()
|
||||
self.log.info("Stopped")
|
||||
|
||||
def confirm(self):
|
||||
if self.state == SudState.WAIT_USER:
|
||||
@@ -209,6 +232,7 @@ class Sud(AttributeChange):
|
||||
def temp_reached(self):
|
||||
if self.state != SudState.RAMPING:
|
||||
return
|
||||
self.log.info("{}({}) Reached".format(self.step.get('descr'), self.step['number']))
|
||||
if 'hold' in self.step:
|
||||
# Same step, ramp phase done - move into its hold phase rather
|
||||
# than finishing the step. Re-assign self.step (AttributeChange
|
||||
@@ -243,6 +267,7 @@ class Sud(AttributeChange):
|
||||
self.state = SudState.DONE
|
||||
self.user_message = None
|
||||
self.step = None
|
||||
self.log.info("Finished")
|
||||
return
|
||||
|
||||
next_step = self.schedule[self.index]
|
||||
@@ -254,9 +279,11 @@ class Sud(AttributeChange):
|
||||
self.state = SudState.RAMPING
|
||||
self.user_message = next_step.get('user_message')
|
||||
self.step = next_step
|
||||
self.log.info("{}({}) Heating".format(next_step.get('descr'), next_step['number']))
|
||||
|
||||
def _finish_step(self):
|
||||
if self.step.get('user_wait_for_continue', False):
|
||||
self.state = SudState.WAIT_USER
|
||||
self.log.info("Waiting for user interaction")
|
||||
else:
|
||||
self._advance()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from components.plant import Pot
|
||||
from components.pid import PidFactory
|
||||
from components.sud import Sud, SudState
|
||||
@@ -7,6 +8,14 @@ from components.sud import Sud, SudState
|
||||
# simulation forever - the estimate is simply cut off there.
|
||||
MAX_TICKS = 200000
|
||||
|
||||
# estimate() below drives a throwaway Sud through an entire schedule in a
|
||||
# tight loop (no real waiting) purely to predict its duration - since it's
|
||||
# still a Sud, it would otherwise log through the exact same "Sud" logger
|
||||
# the real, server-driven one uses (components/sud.py's self.log), making
|
||||
# an instant internal forecast run indistinguishable in the log from an
|
||||
# actual brew. Quiet by default; raise this logger's own level to see it.
|
||||
logging.getLogger('SudForecastEstimator').setLevel(logging.WARNING)
|
||||
|
||||
# Mirrors tasks/heater.py's own pulse_period_s - HeaterTask duty-cycles its
|
||||
# device's discrete power steps over a rolling window this many *real*
|
||||
# seconds wide, regardless of warp factor (see estimate()'s actuate()
|
||||
@@ -95,6 +104,7 @@ class SudForecastEstimator:
|
||||
start_theta = self.theta_amb
|
||||
|
||||
sud = Sud(self.pot_config)
|
||||
sud.log = logging.getLogger('SudForecastEstimator')
|
||||
if not sud.load(doc) or not sud.schedule:
|
||||
return [0.0], [start_theta], SudState.DONE, {}
|
||||
|
||||
|
||||
Binary file not shown.
Executable
+85
@@ -0,0 +1,85 @@
|
||||
<!--Pololu Simple Motor Controller settings file. http://www.pololu.com/docs/0J44-->
|
||||
<!--Created on: 2026-07-07 19:14:45-->
|
||||
<!--Device model: Pololu Simple High-Power Motor Controller 18v15-->
|
||||
<!--Device serial number: 53FF-6F06-7277-5049-3950-0767-->
|
||||
<!--Device firmware version: 1.04-->
|
||||
<SmcSettings version="1">
|
||||
<InputMode>SerialUsb</InputMode>
|
||||
<MixingMode>None</MixingMode>
|
||||
<DisableSafeStart>false</DisableSafeStart>
|
||||
<IgnorePotDisconnect>false</IgnorePotDisconnect>
|
||||
<IgnoreErrLineHigh>false</IgnoreErrLineHigh>
|
||||
<NeverSuspend>false</NeverSuspend>
|
||||
<TempLimitGradual>false</TempLimitGradual>
|
||||
<OverTemp Min="700" Max="800" />
|
||||
<LowVinShutoffTimeout>250</LowVinShutoffTimeout>
|
||||
<LowVinShutoffMv>5500</LowVinShutoffMv>
|
||||
<LowVinStartupMv>6000</LowVinStartupMv>
|
||||
<HighVinShutoffMv>25000</HighVinShutoffMv>
|
||||
<SerialMode>Ascii</SerialMode>
|
||||
<SerialDeviceNumber>13</SerialDeviceNumber>
|
||||
<CommandTimeout>10</CommandTimeout>
|
||||
<CrcMode>Disabled</CrcMode>
|
||||
<UartResponseDelay>false</UartResponseDelay>
|
||||
<UseFixedBaudRate>true</UseFixedBaudRate>
|
||||
<FixedBaudRate>115200</FixedBaudRate>
|
||||
<!--Input Settings-->
|
||||
<Rc1>
|
||||
<AlternateUse>None</AlternateUse>
|
||||
<Invert>false</Invert>
|
||||
<ScalingDegree>0</ScalingDegree>
|
||||
<Error Min="2000" Max="10000" />
|
||||
<Input Min="4000" Max="8000" />
|
||||
<InputNeutral Min="5900" Max="6100" />
|
||||
</Rc1>
|
||||
<Rc2>
|
||||
<AlternateUse>None</AlternateUse>
|
||||
<Invert>false</Invert>
|
||||
<ScalingDegree>0</ScalingDegree>
|
||||
<Error Min="2000" Max="10000" />
|
||||
<Input Min="4000" Max="8000" />
|
||||
<InputNeutral Min="5900" Max="6100" />
|
||||
</Rc2>
|
||||
<Analog1>
|
||||
<AlternateUse>None</AlternateUse>
|
||||
<PinMode>Floating</PinMode>
|
||||
<Invert>false</Invert>
|
||||
<ScalingDegree>0</ScalingDegree>
|
||||
<Error Min="0" Max="4095" />
|
||||
<Input Min="40" Max="4055" />
|
||||
<InputNeutral Min="2015" Max="2080" />
|
||||
</Analog1>
|
||||
<Analog2>
|
||||
<AlternateUse>None</AlternateUse>
|
||||
<PinMode>Floating</PinMode>
|
||||
<Invert>false</Invert>
|
||||
<ScalingDegree>0</ScalingDegree>
|
||||
<Error Min="0" Max="4095" />
|
||||
<Input Min="40" Max="4055" />
|
||||
<InputNeutral Min="2015" Max="2080" />
|
||||
</Analog2>
|
||||
<!--Motor Settings-->
|
||||
<PwmPeriodFactor>1</PwmPeriodFactor>
|
||||
<MotorInvert>false</MotorInvert>
|
||||
<SpeedZeroBrakeAmount>0</SpeedZeroBrakeAmount>
|
||||
<SpeedUpdatePeriod>1</SpeedUpdatePeriod>
|
||||
<ForwardLimits>
|
||||
<MaxSpeed>3200</MaxSpeed>
|
||||
<MaxAcceleration>1</MaxAcceleration>
|
||||
<MaxDeceleration>1</MaxDeceleration>
|
||||
<BrakeDuration>0</BrakeDuration>
|
||||
<StartingSpeed>0</StartingSpeed>
|
||||
</ForwardLimits>
|
||||
<ReverseLimits>
|
||||
<MaxSpeed>0</MaxSpeed>
|
||||
<MaxAcceleration>1</MaxAcceleration>
|
||||
<MaxDeceleration>1</MaxDeceleration>
|
||||
<BrakeDuration>0</BrakeDuration>
|
||||
<StartingSpeed>0</StartingSpeed>
|
||||
</ReverseLimits>
|
||||
<!--Advanced Settings-->
|
||||
<PulsePeriod Min="9" Max="100" />
|
||||
<RcTimeout>500</RcTimeout>
|
||||
<ConsecGoodPulses>2</ConsecGoodPulses>
|
||||
<VinMultiplierOffset>0</VinMultiplierOffset>
|
||||
</SmcSettings>
|
||||
File diff suppressed because it is too large
Load Diff
+36
-8
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -9,6 +10,7 @@ import threading
|
||||
import time
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from utils import ChangedFloat
|
||||
from utils.config_validate import validate_temp_ctrl
|
||||
from ws.message import MessageDispatcher
|
||||
from ws.server.ws_server_multi_user import WsServerMultiUser
|
||||
from components.pid import PidFactory
|
||||
@@ -20,19 +22,40 @@ from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, Stir
|
||||
|
||||
import argparse as ap
|
||||
|
||||
log = logging.getLogger('brewpi')
|
||||
|
||||
|
||||
class Tee:
|
||||
"""Duplicates writes to multiple streams - used to mirror stdout/stderr
|
||||
(everything the rest of the codebase already reaches via print()) into
|
||||
a log file under logs/ as well as the console, without having to touch
|
||||
every print() call site."""
|
||||
into a log file under logs/ as well as the console, without every
|
||||
writer needing to know about either.
|
||||
|
||||
Also stamps every line with a "<date>T<time>:" prefix. Every task/
|
||||
component logs via logging (see utils/value.py's AttributeChange, which
|
||||
gives self.log to all of them), configured below with a bare
|
||||
"%(name)s:%(message)s" formatter - Tee supplies the timestamp instead,
|
||||
so the combined line reads "<date>T<time>:<component>:<message>" (see
|
||||
logging.basicConfig() call below) without double-stamping. This lets
|
||||
log entries be correlated with the JSON sample logs (ServerLogTask/
|
||||
SudLogTask) without relying on journalctl's own timestamps, which
|
||||
aren't present in the mirrored log files themselves."""
|
||||
|
||||
def __init__(self, *streams):
|
||||
self.streams = streams
|
||||
self._at_line_start = True
|
||||
|
||||
def write(self, data):
|
||||
if not data:
|
||||
return
|
||||
out = []
|
||||
for line in data.splitlines(keepends=True):
|
||||
if self._at_line_start:
|
||||
out.append(time.strftime("%Y-%m-%dT%H:%M:%S:", time.localtime()))
|
||||
out.append(line)
|
||||
self._at_line_start = line.endswith('\n')
|
||||
text = ''.join(out)
|
||||
for stream in self.streams:
|
||||
stream.write(data)
|
||||
stream.write(text)
|
||||
|
||||
def flush(self):
|
||||
for stream in self.streams:
|
||||
@@ -74,9 +97,14 @@ if __name__ == '__main__':
|
||||
latest_log_file = open(latest_log_path, "w", buffering=1)
|
||||
sys.stdout = Tee(sys.stdout, log_file, latest_log_file)
|
||||
sys.stderr = Tee(sys.stderr, log_file, latest_log_file)
|
||||
print("Logging to {}".format(log_path))
|
||||
# No "%(asctime)s" here - Tee (above) already stamps every mirrored
|
||||
# line with "<date>T<time>:", so this only contributes the
|
||||
# "<component>:<message>" part (see Tee's own docstring).
|
||||
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(name)s:%(message)s')
|
||||
log.info("Logging to {}".format(log_path))
|
||||
|
||||
config = json.load(open(args.config))
|
||||
validate_temp_ctrl(config)
|
||||
dispatcher = MessageDispatcher()
|
||||
server = WsServerMultiUser(listener=dispatcher)
|
||||
taskmgr = TaskManager()
|
||||
@@ -228,7 +256,7 @@ if __name__ == '__main__':
|
||||
http_handler = functools.partial(SimpleHTTPRequestHandler, directory=web_dir)
|
||||
http_server = ThreadingHTTPServer(("0.0.0.0", args.http_port), http_handler)
|
||||
threading.Thread(target=http_server.serve_forever, daemon=True).start()
|
||||
print("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port))
|
||||
log.info("Serving {} on http://0.0.0.0:{}".format(web_dir, args.http_port))
|
||||
|
||||
# systemd's `stop`/`restart` send SIGTERM, whose default disposition is to
|
||||
# kill the process immediately - skipping the finally: block below
|
||||
@@ -241,7 +269,7 @@ if __name__ == '__main__':
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
log.info("Shutting down...")
|
||||
finally:
|
||||
# Cancel all tasks so their finally blocks (e.g. HeaterTask's/
|
||||
# StirrerTask's `with device.open()`) get a chance to run before we
|
||||
@@ -260,4 +288,4 @@ if __name__ == '__main__':
|
||||
stirrer.activate(False)
|
||||
http_server.shutdown()
|
||||
loop.close()
|
||||
print("Server stopped.")
|
||||
log.info("Server stopped.")
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
"volumen": 36
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
@@ -62,15 +62,15 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"descr": "1. Rast",
|
||||
"descr": "Protease-Rast",
|
||||
"temperature": 55,
|
||||
"hold": {
|
||||
"duration": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"descr": "Glucose-Rast",
|
||||
"temperature": 63,
|
||||
"descr": "Maltose-Rast",
|
||||
"temperature": 62,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
@@ -104,7 +104,7 @@
|
||||
"descr": "Abmaischen",
|
||||
"user_message": "Quittieren zum Abmaischen",
|
||||
"user_wait_for_continue": true,
|
||||
"temperature": 76,
|
||||
"temperature": 78,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
|
||||
+20
-7
@@ -33,6 +33,7 @@ class HeaterTask(ATask):
|
||||
self._on_connected_changed = callback
|
||||
|
||||
def on_connected_changed(self, value):
|
||||
self.log.info("Connected" if value else "Disconnected")
|
||||
fire_and_forget(self.send({'Connected': value}))
|
||||
if self._on_connected_changed:
|
||||
self._on_connected_changed(value)
|
||||
@@ -55,6 +56,20 @@ class HeaterTask(ATask):
|
||||
self._on_closed_loop_changed(True)
|
||||
asyncio.create_task(self.send({'ClosedLoop': True}))
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnects the device and zeroes its setpoint, so a later
|
||||
reconnect (whether from an explicit Connect or an auto-reconnect)
|
||||
never resumes at a stale power. Used for every disconnect - a
|
||||
user-requested Disconnect, a comm-error timeout, and the
|
||||
unexpected-exception recovery path alike - not just the explicit
|
||||
one, since power_actor otherwise keeps whatever the TC last
|
||||
computed (which can be stale/wound-up from before the outage) and
|
||||
would be re-applied on the very next tick after reconnecting."""
|
||||
self.device.disconnect()
|
||||
self.power_soll = 0
|
||||
self.power_actor = 0
|
||||
self.power_set_changed(0)
|
||||
|
||||
def actor(self, y):
|
||||
self.power_actor = max(0, self.device.get_power_max() * y)
|
||||
|
||||
@@ -78,9 +93,7 @@ class HeaterTask(ATask):
|
||||
self.device.activate(True)
|
||||
elif pair[0] == 'Disconnect':
|
||||
self.device.activate(False)
|
||||
self.device.disconnect()
|
||||
self.power_soll = 0
|
||||
self.power_set_changed(0)
|
||||
self.disconnect()
|
||||
elif 'Power' in pair[0]:
|
||||
self.power_soll = pair[1]
|
||||
|
||||
@@ -166,13 +179,13 @@ class HeaterTask(ATask):
|
||||
try:
|
||||
self.device.process()
|
||||
except Exception as e:
|
||||
print(f"HeaterTask: comm error, marking disconnected: {e}")
|
||||
self.device.disconnect()
|
||||
self.log.error(f"comm error, marking disconnected: {e}")
|
||||
self.disconnect()
|
||||
await asyncio.sleep(self.interval)
|
||||
except Exception as e:
|
||||
print(f"HeaterTask: unexpected error, recovering: {e}")
|
||||
self.log.error(f"unexpected error, recovering: {e}")
|
||||
try:
|
||||
self.device.disconnect()
|
||||
self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
@@ -143,4 +143,3 @@ class ServerLogTask(ATask):
|
||||
for name in (filename, self._latest_filename()):
|
||||
with open(os.path.join(self.path, name), 'w') as f:
|
||||
json.dump(log_data, f, indent='\t')
|
||||
print('Server log: wrote {} samples to {}'.format(len(self._samples), filename))
|
||||
|
||||
+17
-6
@@ -33,10 +33,22 @@ class StirrerTask(ATask):
|
||||
self._on_connected_changed = callback
|
||||
|
||||
def on_connected_changed(self, value):
|
||||
self.log.info("Connected" if value else "Disconnected")
|
||||
fire_and_forget(self.send({'Connected': value}))
|
||||
if self._on_connected_changed:
|
||||
self._on_connected_changed(value)
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnects the device and zeroes its speed setpoint, so a later
|
||||
reconnect never resumes spinning at whatever speed was last set.
|
||||
Used for every disconnect - a user-requested Disconnect, a
|
||||
comm-error timeout, and the unexpected-exception recovery path
|
||||
alike - not just the explicit one, since self.device.speed
|
||||
otherwise still holds the old value and the duty-cycle pulsing in
|
||||
AStirrer.process() re-sends it on the next isOn transition."""
|
||||
self.device.disconnect()
|
||||
self.device.set_speed(0.0)
|
||||
|
||||
async def recv(self, data):
|
||||
for pair in data.items():
|
||||
if pair[0] == 'Connect':
|
||||
@@ -44,8 +56,7 @@ class StirrerTask(ATask):
|
||||
self.device.activate(True)
|
||||
elif pair[0] == 'Disconnect':
|
||||
self.device.activate(False)
|
||||
self.device.disconnect()
|
||||
self.device.set_speed(0.0)
|
||||
self.disconnect()
|
||||
elif 'Speed' in pair[0]:
|
||||
self.device.set_speed(pair[1])
|
||||
|
||||
@@ -78,13 +89,13 @@ class StirrerTask(ATask):
|
||||
try:
|
||||
self.device.process()
|
||||
except Exception as e:
|
||||
print(f"StirrerTask: comm error, marking disconnected: {e}")
|
||||
self.device.disconnect()
|
||||
self.log.error(f"comm error, marking disconnected: {e}")
|
||||
self.disconnect()
|
||||
await asyncio.sleep(self.interval)
|
||||
except Exception as e:
|
||||
print(f"StirrerTask: unexpected error, recovering: {e}")
|
||||
self.log.error(f"unexpected error, recovering: {e}")
|
||||
try:
|
||||
self.device.disconnect()
|
||||
self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
+11
-6
@@ -634,7 +634,7 @@ class SudTask(ATask):
|
||||
await self.msg_handler.send(data)
|
||||
|
||||
async def on_process(self):
|
||||
print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval))
|
||||
self.log.info("Started with interval {} s".format(self.interval))
|
||||
|
||||
self.sud.set_on_changed('step', self.on_step_changed)
|
||||
self.sud.set_on_changed('state', self.on_state_changed)
|
||||
@@ -653,11 +653,16 @@ class SudTask(ATask):
|
||||
# wiring), in Watts; dt is this tick's simulated seconds, so
|
||||
# the product is Joules, accumulated for whichever step is
|
||||
# current (see on_step_changed()). Counted through every
|
||||
# non-idle state, WAIT_USER/PAUSED included - the controller
|
||||
# stays enabled (see on_state_changed()) and may well still
|
||||
# be actively holding, drawing real power, even though the
|
||||
# schedule itself isn't progressing.
|
||||
if self.sud.state not in (SudState.IDLE, SudState.DONE):
|
||||
# state except DONE, IDLE included - a manually-driven heater
|
||||
# (no schedule loaded/started yet, e.g. pre-heating strike
|
||||
# water by hand) still draws real power and should show up in
|
||||
# the same total; self.sud.index is a stable -1 while IDLE
|
||||
# (see components/sud.py's _reset_run_state()), so this banks
|
||||
# into energy_by_step[-1] same as any other step once a real
|
||||
# Start moves the index on - never shown per-step (Progress
|
||||
# tab's step-plates only cover the schedule's own indices) but
|
||||
# still included in the status line's summed total.
|
||||
if self.sud.state != SudState.DONE:
|
||||
self.energy_step_accum_j += self.pot.get_power() * self.dt
|
||||
self._energy_changed.set(self.energy_step_accum_j / 3600.0)
|
||||
self.sud.tick(self.dt)
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ class TempSensorTask(ATask):
|
||||
try:
|
||||
self.sensor.temperature()
|
||||
except Exception as e:
|
||||
print(f"TempSensorTask: comm error priming sensor: {e}")
|
||||
self.log.error(f"comm error priming sensor: {e}")
|
||||
self.sensor.set_on_changed("temp", ChangedFloat(self.on_temp_changed, prec=1).set)
|
||||
|
||||
def on_temp_changed(self, value):
|
||||
@@ -44,5 +44,5 @@ class TempSensorTask(ATask):
|
||||
try:
|
||||
self.sensor.temperature()
|
||||
except Exception as e:
|
||||
print(f"TempSensorTask: comm error reading sensor: {e}")
|
||||
self.log.error(f"comm error reading sensor: {e}")
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import unittest
|
||||
|
||||
from components.pid.temp_controller_base import TempControllerBase
|
||||
|
||||
|
||||
def _make(beta=0.05, dt=1.0):
|
||||
ctrl = TempControllerBase(dt=dt)
|
||||
ctrl.beta = beta
|
||||
return ctrl
|
||||
|
||||
|
||||
class TestComputeHeatrateFirstTick(unittest.TestCase):
|
||||
"""Regression test for the historical bug (see README's "Forecast vs.
|
||||
actual duration" section): both TempController variants used to
|
||||
hardcode last_theta_ist = 20 at construction, producing a bogus
|
||||
heatrate_ist spike on the very first tick whenever the real starting
|
||||
temperature wasn't actually 20 - most visible in SudForecastEstimator,
|
||||
which builds a fresh controller on every call. last_theta_ist now
|
||||
starts None and the first tick's rate reads as 0 regardless of the
|
||||
absolute starting temperature."""
|
||||
|
||||
def test_first_tick_reads_zero_rate_regardless_of_starting_temperature(self):
|
||||
for starting_temp in (0.0, 20.0, 87.3, -5.0):
|
||||
with self.subTest(starting_temp=starting_temp):
|
||||
ctrl = _make()
|
||||
ctrl._compute_heatrate(starting_temp)
|
||||
self.assertEqual(ctrl.heatrate_ist, 0.0)
|
||||
|
||||
|
||||
class TestComputeHeatrateConvergence(unittest.TestCase):
|
||||
"""For a perfectly linear ramp, both low-pass stages (the beta
|
||||
pre-filter on theta_ist, then the alpha filter on the differentiated
|
||||
rate) settle to the true underlying rate once their transient decays -
|
||||
a linear signal's slope survives exponential smoothing unchanged."""
|
||||
|
||||
def _run_ramp(self, rate_per_min, ticks=400, dt=1.0, start=30.0):
|
||||
ctrl = _make(dt=dt)
|
||||
theta = start
|
||||
for _ in range(ticks):
|
||||
theta += rate_per_min / 60.0 * dt
|
||||
ctrl._compute_heatrate(theta)
|
||||
return ctrl.heatrate_ist
|
||||
|
||||
def test_converges_to_true_heating_rate(self):
|
||||
self.assertAlmostEqual(self._run_ramp(1.2), 1.2, delta=0.01)
|
||||
|
||||
def test_converges_to_true_cooling_rate(self):
|
||||
self.assertAlmostEqual(self._run_ramp(-0.8), -0.8, delta=0.01)
|
||||
|
||||
|
||||
class TestComputeHeatratePrefilterSuppressesNoise(unittest.TestCase):
|
||||
"""Filtering theta_ist before differentiating (see _compute_heatrate()'s
|
||||
own docstring) keeps stirrer-induced temperature wobble from being
|
||||
amplified into heatrate_ist noise of the same order as a typical
|
||||
rate_soll. beta=1.0 disables the pre-filter entirely (theta_ist_filtered
|
||||
reduces to the raw reading each tick), giving a same-signal comparison
|
||||
against the real default beta."""
|
||||
|
||||
def _peak_abs_heatrate(self, beta, amplitude=0.05, period=4, ticks=300):
|
||||
ctrl = _make(beta=beta)
|
||||
peak = 0.0
|
||||
for i in range(ticks):
|
||||
wobble = amplitude if (i // (period // 2)) % 2 == 0 else -amplitude
|
||||
ctrl._compute_heatrate(30.0 + wobble)
|
||||
if i > 20: # past the initial filter transient
|
||||
peak = max(peak, abs(ctrl.heatrate_ist))
|
||||
return peak
|
||||
|
||||
def test_prefilter_reduces_noise_amplification(self):
|
||||
filtered_peak = self._peak_abs_heatrate(beta=0.05)
|
||||
raw_peak = self._peak_abs_heatrate(beta=1.0)
|
||||
|
||||
# The unfiltered case must actually show strong noise amplification
|
||||
# for this comparison to mean anything.
|
||||
self.assertGreater(raw_peak, 0.25)
|
||||
self.assertLess(filtered_peak, raw_peak / 2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,116 @@
|
||||
import unittest
|
||||
|
||||
from components.pid.temp_controller_smith import TempController
|
||||
from components.plant.pot import Pot
|
||||
|
||||
DT = 1.0
|
||||
AMBIENT = 20.0
|
||||
PLANT_PARAMS = {"M": 27.96, "C": 3403.43, "L": 0.2, "Td": 17}
|
||||
GAINS = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
|
||||
|
||||
|
||||
def make_controller(model_params):
|
||||
ctrl = TempController(DT)
|
||||
ctrl.set_params({
|
||||
"beta": 0.9,
|
||||
"Outer": dict(GAINS),
|
||||
"Inner": {"Heat": dict(GAINS), "Hold": dict(GAINS), "Cool": dict(GAINS)},
|
||||
})
|
||||
ctrl.set_model_plant_params(model_params)
|
||||
ctrl.set_ambient_temperature(AMBIENT)
|
||||
ctrl.set_enabled(True)
|
||||
return ctrl
|
||||
|
||||
|
||||
def make_plant(initial_temp):
|
||||
plant = Pot(DT)
|
||||
plant.set_plant_params(PLANT_PARAMS)
|
||||
plant.set_ambient_temperature(AMBIENT)
|
||||
plant.initial(initial_temp)
|
||||
return plant
|
||||
|
||||
|
||||
def step(ctrl, plant, power):
|
||||
"""Open-loop: power is an externally chosen step function, not fed back
|
||||
from ctrl.get_power() - isolates the Smith correction's own math from
|
||||
PID tuning. Ordering (plant.process() -> read theta_ist -> ctrl.process()
|
||||
-> only then set next tick's power on both plant and model) mirrors
|
||||
tasks/sud.py's real wiring and test_temp_controller_closed_loop.py's own
|
||||
tick() helper, keeping plant and internal model symmetrically one tick
|
||||
behind whatever power was last commanded."""
|
||||
plant.process()
|
||||
ctrl.set_theta_ist(plant.get_temperature())
|
||||
ctrl.set_theta_soll(100.0)
|
||||
ctrl.set_heatrate_soll(1.0)
|
||||
ctrl.process()
|
||||
plant.set_power(power)
|
||||
ctrl.set_model_power(power)
|
||||
|
||||
|
||||
def _run_power_step(ctrl, plant, ticks=200, power=2000.0):
|
||||
max_gap = 0.0
|
||||
for i in range(ticks):
|
||||
step(ctrl, plant, power if i > 0 else 0.0)
|
||||
max_gap = max(max_gap, abs(ctrl.theta_ist - ctrl.theta_ist_model))
|
||||
return max_gap
|
||||
|
||||
|
||||
class TestSmithCorrectionTracksFastModelWhenMatched(unittest.TestCase):
|
||||
"""theta_ist = theta_ist_model + (theta_ist_plant - theta_ist_model_delay)
|
||||
(temp_controller_smith.py's process()): when the internal model's plant
|
||||
params exactly match the real plant's, model_delay tracks the real
|
||||
plant so closely that the correction term stays near zero and theta_ist
|
||||
reduces to the fast (zero transport-delay) model's own prediction -
|
||||
the core Smith-predictor identity. A small residual persists even here:
|
||||
theta_ist_model_delay is one tick behind theta_ist_plant by construction
|
||||
(post_pid() advances the models after this tick's comparison, so a real
|
||||
plant.process() call always has a one-tick head start - see step()'s
|
||||
own comment), so this checks a small bound, not exact equality."""
|
||||
|
||||
def test_matched_model_stays_close_to_fast_model(self):
|
||||
ctrl = make_controller(PLANT_PARAMS)
|
||||
plant = make_plant(AMBIENT)
|
||||
self.assertLess(_run_power_step(ctrl, plant), 0.1)
|
||||
|
||||
def test_mismatched_model_diverges_much_further(self):
|
||||
# 4x the real mass - the internal model predicts a much slower,
|
||||
# smaller temperature response than the real plant actually has.
|
||||
bad_params = {**PLANT_PARAMS, "M": PLANT_PARAMS["M"] * 4}
|
||||
ctrl = make_controller(bad_params)
|
||||
plant = make_plant(AMBIENT)
|
||||
mismatched_gap = _run_power_step(ctrl, plant)
|
||||
|
||||
matched_gap = _run_power_step(make_controller(PLANT_PARAMS), make_plant(AMBIENT))
|
||||
|
||||
# Confirms the tight bound above is actually meaningful (the
|
||||
# correction mechanism responds to a wrong model), not just always
|
||||
# true regardless of whether the model matches anything.
|
||||
self.assertGreater(mismatched_gap, matched_gap * 10)
|
||||
|
||||
|
||||
class TestSmithCorrectionSeesThroughDeadTime(unittest.TestCase):
|
||||
"""The whole point of the two-model correction: theta_ist must start
|
||||
responding to a commanded power step well before Td ticks have elapsed,
|
||||
even though the real (delayed) plant's own raw temperature - Pot.process()'s
|
||||
transport-delay line - hasn't moved yet. Without this, the PID would only
|
||||
ever see the effect of a power change Td seconds after commanding it."""
|
||||
|
||||
def test_corrected_estimate_rises_before_raw_plant_reading_does(self):
|
||||
ctrl = make_controller(PLANT_PARAMS)
|
||||
plant = make_plant(AMBIENT)
|
||||
|
||||
ticks_well_inside_dead_time = 5
|
||||
self.assertLess(ticks_well_inside_dead_time, PLANT_PARAMS["Td"])
|
||||
|
||||
for i in range(ticks_well_inside_dead_time):
|
||||
step(ctrl, plant, 2000.0 if i > 0 else 0.0)
|
||||
|
||||
# Raw plant reading: still at the transport delay's input queue,
|
||||
# not showing any temperature rise yet.
|
||||
self.assertEqual(ctrl.theta_ist_plant, AMBIENT)
|
||||
# Corrected estimate: already rising, fed by the zero-delay model.
|
||||
self.assertGreater(ctrl.theta_ist, AMBIENT + 0.01)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from components.sud import Sud
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'utils', 'sud_fixtures')
|
||||
|
||||
|
||||
def _load_fixture(name):
|
||||
with open(os.path.join(FIXTURES_DIR, name)) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestSudLoadRejectsMalformedDocs(unittest.TestCase):
|
||||
"""Sud.load() wires in utils/sud_validate.py's validate_sud() so a
|
||||
malformed sud.json is refused (same False-returning contract as any
|
||||
other load() failure - see components/sud.py's own docstring) instead
|
||||
of parsing cleanly and only crashing much later, mid-run or during a
|
||||
forecast simulation, on the unguarded ramp['rate'] lookup."""
|
||||
|
||||
def test_valid_doc_loads(self):
|
||||
sud = Sud()
|
||||
self.assertTrue(sud.load(_load_fixture('valid_multi_step.json')))
|
||||
self.assertEqual(sud.name, 'MultiStep')
|
||||
|
||||
def test_missing_ramp_rate_is_refused(self):
|
||||
sud = Sud()
|
||||
self.assertFalse(sud.load(_load_fixture('missing_ramp_rate.json')))
|
||||
|
||||
def test_wrong_type_ramp_rate_is_refused(self):
|
||||
sud = Sud()
|
||||
self.assertFalse(sud.load(_load_fixture('ramp_rate_wrong_type.json')))
|
||||
|
||||
def test_empty_steps_is_refused(self):
|
||||
sud = Sud()
|
||||
self.assertFalse(sud.load(_load_fixture('empty_steps_list.json')))
|
||||
|
||||
def test_failed_load_leaves_previous_schedule_untouched(self):
|
||||
sud = Sud()
|
||||
self.assertTrue(sud.load(_load_fixture('valid_multi_step.json')))
|
||||
self.assertFalse(sud.load(_load_fixture('missing_ramp_rate.json')))
|
||||
self.assertEqual(sud.name, 'MultiStep')
|
||||
self.assertEqual(len(sud.schedule), 3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
},
|
||||
"Kalman": {
|
||||
"var_P": 1.0,
|
||||
"var_Q": 0.01,
|
||||
"var_R": 0.1
|
||||
},
|
||||
"Model": {
|
||||
"kn": 0.0,
|
||||
"gain": 0.8
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
},
|
||||
"Plant": {
|
||||
"kn": 0.0,
|
||||
"gain": 0.8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Normal",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "hendi",
|
||||
"port": "/dev/ttyUSB0",
|
||||
"speed": "115200"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "pololu1376",
|
||||
"port": "/dev/ttyACM0",
|
||||
"speed": "115200"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "max31865",
|
||||
"temp_offset": -0.15
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"beta": 0.05,
|
||||
"Outer": {
|
||||
"kp": 0.4,
|
||||
"ki": 0.0,
|
||||
"kd": 0.0,
|
||||
"kt": 0.0,
|
||||
"y_hold_min": -0.1
|
||||
},
|
||||
"Inner": {
|
||||
"Heat": {
|
||||
"kp": "0.08",
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
},
|
||||
"Hold": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5,
|
||||
"yi_max": 0.3
|
||||
},
|
||||
"Cool": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
},
|
||||
"Thresholds": {
|
||||
"HoldHeat": 1.0,
|
||||
"HoldCool": 1.0,
|
||||
"HeatHold": 1.0,
|
||||
"HeatCool": 1.0,
|
||||
"CoolHold": 1.0,
|
||||
"CoolHeat": 1.0
|
||||
}
|
||||
},
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"log_interval": 300,
|
||||
"TempCtrl": "Smith",
|
||||
"Heater": {
|
||||
"type": "sim"
|
||||
},
|
||||
"Stirrer": {
|
||||
"type": "sim"
|
||||
},
|
||||
"TempSensor": {
|
||||
"type": "sim",
|
||||
"temp_offset": 0.0,
|
||||
"sigma": 0.05,
|
||||
"stirrer_sigma": 0.0,
|
||||
"stirrer_tau": 20.0
|
||||
},
|
||||
"Pot": {
|
||||
"mass": 5.96,
|
||||
"material": "Edelstahl 18/10",
|
||||
"L": 0.2,
|
||||
"Td": 17,
|
||||
"water_mass": 22,
|
||||
"volumen": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Name": "EmptySteps",
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"Name": "MissingRampRate",
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 20
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"hold": {
|
||||
"duration": 0
|
||||
},
|
||||
"ramp": {}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"descr": "Heat up",
|
||||
"temperature": 65
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Name": "NoSteps",
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"Name": "WrongType",
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 20
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"descr": "Heat up",
|
||||
"temperature": 65,
|
||||
"ramp": {
|
||||
"rate": "fast"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Name": "StepNotDict",
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
"not a step object"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"Name": "Minimal",
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 20
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"descr": "Only step"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Name": "MultiStep",
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 22
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"descr": "Aufheizen",
|
||||
"temperature": 57,
|
||||
"ramp": {
|
||||
"rate": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"descr": "Rast",
|
||||
"temperature": 63,
|
||||
"hold": {
|
||||
"duration": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"descr": "Ausmaischen",
|
||||
"temperature": 78,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"Name": "WithTarget",
|
||||
"pot": {
|
||||
"grain_mass": 0,
|
||||
"water_mass": 20
|
||||
},
|
||||
"default": {
|
||||
"step": {
|
||||
"descr": "Put description here",
|
||||
"user_wait_for_continue": false,
|
||||
"temperature": 0,
|
||||
"ramp": {
|
||||
"rate": 1.0
|
||||
},
|
||||
"hold": {
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"descr": "Heat up",
|
||||
"temperature": 65
|
||||
},
|
||||
{
|
||||
"descr": "Hold",
|
||||
"hold": {
|
||||
"duration": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from utils.config_validate import ConfigError, validate_temp_ctrl
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures')
|
||||
|
||||
# filename -> None if the fixture should pass validation, otherwise a regex
|
||||
# matched against the raised ConfigError's message.
|
||||
CASES = {
|
||||
'valid_sim.json': None,
|
||||
'valid_real.json': None,
|
||||
'valid_normal.json': None,
|
||||
'legacy_unknown_keys.json': None,
|
||||
'missing_outer_kp.json': r'TempCtrl\.Outer\.kp',
|
||||
'missing_inner_hold_branch.json': r'TempCtrl\.Inner\.Hold',
|
||||
'missing_pid_type.json': r'TempCtrl\.pid_type',
|
||||
'missing_tempctrl_section.json': r'missing required config key: TempCtrl$',
|
||||
'wrong_type_kp_string.json': r'TempCtrl\.Inner\.Heat\.kp',
|
||||
'wrong_type_tempctrl_not_dict.json': r'TempCtrl must be dict',
|
||||
}
|
||||
|
||||
|
||||
def _load(name):
|
||||
with open(os.path.join(FIXTURES_DIR, name)) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _test_name(filename):
|
||||
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
|
||||
|
||||
|
||||
class TestConfigFixtures(unittest.TestCase):
|
||||
def test_every_fixture_file_is_covered(self):
|
||||
on_disk = {f for f in os.listdir(FIXTURES_DIR) if f.endswith('.json')}
|
||||
self.assertEqual(on_disk, set(CASES), "fixtures/ and CASES have drifted apart")
|
||||
|
||||
|
||||
def _make_fixture_test(name, expected_error):
|
||||
def test(self):
|
||||
config = _load(name)
|
||||
if expected_error is None:
|
||||
validate_temp_ctrl(config) # must not raise
|
||||
else:
|
||||
with self.assertRaisesRegex(ConfigError, expected_error):
|
||||
validate_temp_ctrl(config)
|
||||
return test
|
||||
|
||||
|
||||
# One dedicated test method per fixture file - not a single method looping
|
||||
# over CASES with subTest - so `unittest discover -v` (and any IDE test
|
||||
# runner) lists and can re-run each fixture individually instead of folding
|
||||
# them all into one opaque "did any of these fail" result.
|
||||
for _name, _expected_error in CASES.items():
|
||||
setattr(TestConfigFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
import unittest
|
||||
|
||||
from utils.config_validate import ConfigError, require, require_schema, validate_temp_ctrl
|
||||
|
||||
|
||||
def _valid_temp_ctrl_config():
|
||||
gains = {"kp": 0.08, "ki": 0.02, "kd": 0.0, "kt": 1.5}
|
||||
return {
|
||||
"TempCtrl": {
|
||||
"pid_type": "Smith",
|
||||
"Outer": dict(gains),
|
||||
"Inner": {
|
||||
"Heat": dict(gains),
|
||||
"Hold": dict(gains),
|
||||
"Cool": dict(gains),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestRequire(unittest.TestCase):
|
||||
def test_returns_value_when_present(self):
|
||||
self.assertEqual(require({"a": {"b": 1}}, "a.b"), 1)
|
||||
|
||||
def test_raises_on_missing_leaf(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({"a": {}}, "a.b")
|
||||
|
||||
def test_raises_on_missing_intermediate(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({}, "a.b")
|
||||
|
||||
def test_error_names_full_path_up_to_missing_key(self):
|
||||
with self.assertRaisesRegex(ConfigError, r"a\.b"):
|
||||
require({"a": {}}, "a.b.c")
|
||||
|
||||
def test_raises_on_wrong_type(self):
|
||||
with self.assertRaises(ConfigError):
|
||||
require({"a": "not a number"}, "a", type=(int, float))
|
||||
|
||||
def test_accepts_matching_type(self):
|
||||
self.assertEqual(require({"a": 1.5}, "a", type=(int, float)), 1.5)
|
||||
|
||||
|
||||
class TestRequireSchema(unittest.TestCase):
|
||||
"""require_schema() is the generic engine both validate_temp_ctrl()
|
||||
(config.json's TempCtrl section) and utils/sud_validate.py's
|
||||
validate_sud() (a sud.json's fixed 'steps' shape) are built on - tested
|
||||
here directly, against a schema unrelated to either, to confirm it's
|
||||
not accidentally coupled to TempCtrl's specific shape."""
|
||||
|
||||
SCHEMA = {'a': {'b': int, 'c': (int, float)}, 'd': str}
|
||||
|
||||
def test_valid_document_passes(self):
|
||||
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x'}, self.SCHEMA)
|
||||
|
||||
def test_missing_nested_leaf_raises(self):
|
||||
with self.assertRaisesRegex(ConfigError, r'a\.b'):
|
||||
require_schema({'a': {'c': 2.5}, 'd': 'x'}, self.SCHEMA)
|
||||
|
||||
def test_missing_top_level_leaf_raises(self):
|
||||
with self.assertRaisesRegex(ConfigError, r'missing required config key: d$'):
|
||||
require_schema({'a': {'b': 1, 'c': 2.5}}, self.SCHEMA)
|
||||
|
||||
def test_wrong_type_on_nested_object_itself_is_named_directly(self):
|
||||
# 'a' present but not an object - must be reported as 'a' itself,
|
||||
# not as a confusing "missing a.b"/"missing a.c".
|
||||
with self.assertRaisesRegex(ConfigError, r'^config key a must be dict'):
|
||||
require_schema({'a': 'not an object', 'd': 'x'}, self.SCHEMA)
|
||||
|
||||
def test_extra_keys_not_in_schema_are_ignored(self):
|
||||
require_schema({'a': {'b': 1, 'c': 2.5}, 'd': 'x', 'extra': 'stray'}, self.SCHEMA)
|
||||
|
||||
|
||||
class TestValidateTempCtrl(unittest.TestCase):
|
||||
def test_valid_config_passes(self):
|
||||
validate_temp_ctrl(_valid_temp_ctrl_config())
|
||||
|
||||
def test_missing_gain_raises(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
del config["TempCtrl"]["Inner"]["Hold"]["kp"]
|
||||
with self.assertRaisesRegex(ConfigError, r"TempCtrl\.Inner\.Hold\.kp"):
|
||||
validate_temp_ctrl(config)
|
||||
|
||||
def test_missing_pid_type_raises(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
del config["TempCtrl"]["pid_type"]
|
||||
with self.assertRaises(ConfigError):
|
||||
validate_temp_ctrl(config)
|
||||
|
||||
def test_optional_keys_not_required(self):
|
||||
config = _valid_temp_ctrl_config()
|
||||
# Outer.y_hold_min, Inner.Hold.yi_max, and the whole Thresholds
|
||||
# section all have their own defaults elsewhere and must stay
|
||||
# optional here too.
|
||||
validate_temp_ctrl(config)
|
||||
self.assertNotIn("y_hold_min", config["TempCtrl"]["Outer"])
|
||||
self.assertNotIn("Thresholds", config["TempCtrl"])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from utils.config_validate import ConfigError
|
||||
from utils.sud_validate import validate_sud
|
||||
|
||||
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'sud_fixtures')
|
||||
SUDE_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'sude')
|
||||
|
||||
# filename -> None if the fixture should pass validation, otherwise a regex
|
||||
# matched against the raised ConfigError's message.
|
||||
CASES = {
|
||||
'valid_minimal.json': None,
|
||||
'valid_with_target.json': None,
|
||||
'valid_multi_step.json': None,
|
||||
'missing_steps_key.json': r"missing required config key: steps",
|
||||
'empty_steps_list.json': r"'steps' must be a non-empty list",
|
||||
'missing_ramp_rate.json': r'step 1 \(Heat up\): .*ramp\.rate',
|
||||
'ramp_rate_wrong_type.json': r'step 1 \(Heat up\): .*ramp\.rate',
|
||||
'step_not_dict.json': r'malformed sud document',
|
||||
}
|
||||
|
||||
|
||||
def _load(directory, name):
|
||||
with open(os.path.join(directory, name)) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _test_name(filename):
|
||||
return 'test_' + re.sub(r'\W', '_', filename[:-len('.json')])
|
||||
|
||||
|
||||
class TestSudValidateFixtures(unittest.TestCase):
|
||||
def test_every_fixture_file_is_covered(self):
|
||||
on_disk = {f for f in os.listdir(FIXTURES_DIR) if f.endswith('.json')}
|
||||
self.assertEqual(on_disk, set(CASES), "sud_fixtures/ and CASES have drifted apart")
|
||||
|
||||
|
||||
def _make_fixture_test(name, expected_error):
|
||||
def test(self):
|
||||
data = _load(FIXTURES_DIR, name)
|
||||
if expected_error is None:
|
||||
validate_sud(data) # must not raise
|
||||
else:
|
||||
with self.assertRaisesRegex(ConfigError, expected_error):
|
||||
validate_sud(data)
|
||||
return test
|
||||
|
||||
|
||||
# One dedicated test method per fixture file - not a single method looping
|
||||
# over CASES with subTest - so `unittest discover -v` (and any IDE test
|
||||
# runner) lists and can re-run each fixture individually instead of folding
|
||||
# them all into one opaque "did any of these fail" result.
|
||||
for _name, _expected_error in CASES.items():
|
||||
setattr(TestSudValidateFixtures, _test_name(_name), _make_fixture_test(_name, _expected_error))
|
||||
|
||||
|
||||
class TestRealSudFiles(unittest.TestCase):
|
||||
"""The actual sude/*.json schedules must always pass - a failure here
|
||||
means either a real schedule is broken, or validate_sud() itself has
|
||||
drifted from what Sud._parse_data() actually accepts."""
|
||||
|
||||
|
||||
def _make_real_file_test(name):
|
||||
def test(self):
|
||||
validate_sud(_load(SUDE_DIR, name))
|
||||
return test
|
||||
|
||||
|
||||
_real_sud_files = [f for f in os.listdir(SUDE_DIR) if f.endswith('.json')]
|
||||
assert _real_sud_files, "expected at least one sude/*.json file"
|
||||
for _name in _real_sud_files:
|
||||
setattr(TestRealSudFiles, _test_name(_name), _make_real_file_test(_name))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
class ConfigError(Exception):
|
||||
"""Raised for a missing or wrong-typed config key, named by its full
|
||||
dotted path (e.g. "TempCtrl.Inner.Hold.kp") so the error points straight
|
||||
at the offending line in config.json instead of surfacing as a bare
|
||||
KeyError/TypeError from wherever the value first gets used."""
|
||||
|
||||
|
||||
def require(config, path, type=None):
|
||||
"""Generic dotted-path lookup against any nested-dict document - not
|
||||
tied to server config.json's own shape, so this is exactly as usable
|
||||
for a sud.json schedule (see utils/sud_validate.py) as for TempCtrl
|
||||
below."""
|
||||
node = config
|
||||
parts = path.split('.')
|
||||
for i, key in enumerate(parts):
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
raise ConfigError("missing required config key: {}".format('.'.join(parts[:i + 1])))
|
||||
node = node[key]
|
||||
if type is not None and not isinstance(node, type):
|
||||
type_name = ' or '.join(t.__name__ for t in type) if isinstance(type, tuple) else type.__name__
|
||||
raise ConfigError("config key {} must be {}, got {!r}".format(path, type_name, node))
|
||||
return node
|
||||
|
||||
|
||||
def require_schema(config, schema, _prefix=''):
|
||||
"""Generic declarative counterpart to require(): schema mirrors the
|
||||
document's own shape, with every leaf naming the required type(s) (see
|
||||
TEMPCTRL_SCHEMA below for an example) - one shared mechanism for any
|
||||
config-shaped document with a fixed structure, whether that's server
|
||||
config.json's TempCtrl section or (for the parts of it that are static -
|
||||
see utils/sud_validate.py for the parts that aren't, e.g. per-step
|
||||
conditional requirements) a sud.json schedule.
|
||||
|
||||
A schema value that's itself a dict describes a required sub-object:
|
||||
checked (and reported, on failure, as its own dotted path) before
|
||||
recursing into it, so a wrong-typed intermediate node (e.g. 'TempCtrl'
|
||||
itself not being an object) is named directly rather than surfacing as
|
||||
a confusing "missing" error on one of its children instead."""
|
||||
for key, spec in schema.items():
|
||||
path = '{}.{}'.format(_prefix, key) if _prefix else key
|
||||
if isinstance(spec, dict):
|
||||
require(config, path, type=dict)
|
||||
require_schema(config, spec, path)
|
||||
else:
|
||||
require(config, path, type=spec)
|
||||
|
||||
|
||||
_GAIN_SCHEMA = {'kp': (int, float), 'ki': (int, float), 'kd': (int, float), 'kt': (int, float)}
|
||||
|
||||
# Only the gains every pid_type actually reads unconditionally are
|
||||
# required; anything with its own default elsewhere (Outer.y_hold_min,
|
||||
# Inner.Hold.yi_max, the whole Thresholds section - see
|
||||
# temp_controller_base.py/temp_controller_fsm.py's DEFAULT_THRESHOLDS)
|
||||
# stays optional here too, on purpose.
|
||||
TEMPCTRL_SCHEMA = {
|
||||
'TempCtrl': {
|
||||
'pid_type': str,
|
||||
'Outer': _GAIN_SCHEMA,
|
||||
'Inner': {
|
||||
'Heat': _GAIN_SCHEMA,
|
||||
'Hold': _GAIN_SCHEMA,
|
||||
'Cool': _GAIN_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_temp_ctrl(config):
|
||||
"""Checked eagerly at startup so a missing/misnamed PID gain fails with
|
||||
a clear message here instead of a bare KeyError mid-__init__ or on the
|
||||
first process() tick (this bit us once already: config.json.sim's gain/
|
||||
Model nesting mismatch)."""
|
||||
require_schema(config, TEMPCTRL_SCHEMA)
|
||||
@@ -0,0 +1,45 @@
|
||||
from utils.config_validate import ConfigError, require, require_schema
|
||||
from components.sud import Sud
|
||||
|
||||
# The only part of a sud.json document's shape that's fixed regardless of
|
||||
# content - same declarative mechanism validate_temp_ctrl() uses for
|
||||
# config.json's TempCtrl section (see config_validate.py's require_schema()).
|
||||
# Everything else about a schedule (how many steps, which fields each one
|
||||
# overrides vs. inherits from default.step, whether a step needs ramp.rate
|
||||
# at all) depends on the document's own content, not just its shape, so it
|
||||
# can't be expressed as a static schema - handled by the step loop below
|
||||
# instead, still built on the same require()/ConfigError primitives.
|
||||
SUD_SCHEMA = {
|
||||
'steps': list,
|
||||
}
|
||||
|
||||
|
||||
def validate_sud(data):
|
||||
"""Fail-fast structural check for a sud.json document - same rationale
|
||||
as config_validate.py's validate_temp_ctrl(): a malformed field here
|
||||
doesn't surface until deep inside a run, or worse, during
|
||||
SudForecastEstimator's very first Load-time simulation (tasks/sud.py,
|
||||
components/sud_forecast.py and scripts/demos/sud/demo_sud.py all read
|
||||
ramp['rate'] directly, no .get() fallback, the moment any step actually
|
||||
sets its own 'temperature') - as a bare KeyError with no indication of
|
||||
which schedule step or field is wrong.
|
||||
|
||||
Reuses Sud._parse_data() itself (rather than re-implementing default-
|
||||
merging/step-building) so what's checked here can never drift from what
|
||||
actually gets parsed at runtime."""
|
||||
require_schema(data, SUD_SCHEMA)
|
||||
if not data['steps']:
|
||||
raise ConfigError("'steps' must be a non-empty list")
|
||||
|
||||
try:
|
||||
schedule = Sud._parse_data(data)[2]
|
||||
except (KeyError, TypeError, AttributeError) as e:
|
||||
raise ConfigError("malformed sud document: {}".format(e))
|
||||
|
||||
for step in schedule:
|
||||
if step['temperature'] is None:
|
||||
continue
|
||||
try:
|
||||
require(step, 'ramp.rate', type=(int, float))
|
||||
except ConfigError as e:
|
||||
raise ConfigError("step {} ({}): {}".format(step['number'], step.get('descr'), e))
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
|
||||
|
||||
class Value:
|
||||
@@ -53,6 +54,12 @@ class ChangedInteger(object):
|
||||
class AttributeChange(object):
|
||||
def __init__(self):
|
||||
self.callbacks = {}
|
||||
# Common ancestor of every task (tasks/task.py's ATask) and component
|
||||
# (Connectable/APid/APlant/ATemperatureSensor) - giving it a logger
|
||||
# here makes self.log available everywhere via one change, named
|
||||
# after the concrete subclass so log lines self-identify their
|
||||
# component without each call site typing it out.
|
||||
self.log = logging.getLogger(type(self).__name__)
|
||||
|
||||
def set_on_changed(self, key, on_changed):
|
||||
if key not in self.callbacks:
|
||||
|
||||
+83
-35
@@ -153,7 +153,10 @@ function parseSudDoc(doc) {
|
||||
return {
|
||||
name: doc.Name || '',
|
||||
schedule: schedule,
|
||||
potMass: pot.mass || 0,
|
||||
// Falls back to config.json's Pot.mass (via potConfig, set from
|
||||
// the System message) when the loaded doc doesn't override it -
|
||||
// mirrors components/sud.py's Sud._parse_data().
|
||||
potMass: pot.mass || potConfig.mass || 0,
|
||||
grainMass: pot.grain_mass || 0,
|
||||
waterMass: pot.water_mass || 0,
|
||||
volumen: pot.volumen || 0,
|
||||
@@ -476,31 +479,39 @@ function updatePotVisualization() {
|
||||
|
||||
function updateStatusLine() {
|
||||
const el = document.getElementById('sud-status-line');
|
||||
if (sudSchedule.length === 0) {
|
||||
const w = potConfig.water_mass || 0;
|
||||
el.textContent = w > 0 ? `No schedule – ${w} L water` : '';
|
||||
return;
|
||||
}
|
||||
let text = '';
|
||||
let grain = sudGrainMass, water = sudWaterMass;
|
||||
if (sudStepDescr) {
|
||||
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
|
||||
// Progress tab's step-plates (createStepPlate()) label 1-based -
|
||||
// match it here, same fix as client/brewpi_gui.py's
|
||||
// update_status_step_label().
|
||||
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
|
||||
if (sudStepType === 'hold') {
|
||||
const remaining = Math.max(sudHoldRemaining, 0);
|
||||
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
|
||||
}
|
||||
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
|
||||
const step = sudSchedule[sudStepIndex];
|
||||
grain = step.grain_mass || 0;
|
||||
water = step.water_mass || 0;
|
||||
// Before any schedule is loaded, fall back to config.json's Pot
|
||||
// section (via potConfig) so the status line already shows pot mass/
|
||||
// water/volume/energy as soon as the System message arrives - same
|
||||
// sudEmpty ? potConfig... : sud... pattern as updatePotVisualization()
|
||||
// above. Step description/remaining-time only exist once a schedule
|
||||
// is actually loaded, so they stay inside the sudEmpty===false branch.
|
||||
let grain = 0, water = potConfig.water_mass || 0;
|
||||
let pot = potConfig.mass || 0;
|
||||
let volumen = potConfig.volumen || 0;
|
||||
if (!sudEmpty) {
|
||||
grain = sudGrainMass;
|
||||
water = sudWaterMass;
|
||||
pot = sudPotMass;
|
||||
volumen = sudVolumen;
|
||||
if (sudStepDescr) {
|
||||
// +1: sudStepIndex is the 0-based index Sud.index uses, but the
|
||||
// Progress tab's step-plates (createStepPlate()) label 1-based -
|
||||
// match it here, same fix as client/brewpi_gui.py's
|
||||
// update_status_step_label().
|
||||
text = `Step ${sudStepIndex + 1}: ${sudStepDescr}`;
|
||||
if (sudStepType === 'hold') {
|
||||
const remaining = Math.max(sudHoldRemaining, 0);
|
||||
text += ` (${Math.floor(remaining / 60)}:${String(Math.floor(remaining % 60)).padStart(2, '0')} remaining)`;
|
||||
}
|
||||
if (sudStepIndex !== null && sudStepIndex >= 0 && sudStepIndex < sudSchedule.length) {
|
||||
const step = sudSchedule[sudStepIndex];
|
||||
grain = step.grain_mass || 0;
|
||||
water = step.water_mass || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
const pot = sudPotMass;
|
||||
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg`;
|
||||
const massText = `Pot: ${pot.toFixed(2)} kg, Water: ${water.toFixed(2)} kg, Grain ${grain.toFixed(2)} kg, total ${(pot + water + grain).toFixed(2)} kg, Volume ${volumen.toFixed(1)} L`;
|
||||
text = text ? `${text} ${massText}` : massText;
|
||||
const elapsedText = `Elapsed ${elapsed !== null ? formatDuration(elapsed) : '-:--:--'}`;
|
||||
const totalEnergyKwh = (Object.values(energyByStep).reduce((a, b) => a + b, 0) + energyCurrent) / 1000.0;
|
||||
@@ -571,12 +582,34 @@ function updateClosedLoopStatus() {
|
||||
statusEl.className = `panel-status ${closedLoop ? 'status-connected' : 'status-disconnected'}`;
|
||||
}
|
||||
|
||||
// Mirrors client/brewpi_gui.py's show_user_message(): the Sud is already
|
||||
// blocked in WAIT_USER server-side - dismissing this dialog (OK or Escape,
|
||||
// both fire the dialog's 'close' event) is what unblocks it.
|
||||
function showUserMessage(message) {
|
||||
document.getElementById('sud-message-text').textContent = message;
|
||||
document.getElementById('sud-message-dialog').showModal();
|
||||
// Mirrors client/brewpi_gui.py's set_user_confirm_pending(): a persistent
|
||||
// header control rather than a modal dialog. While pending, the Confirm
|
||||
// button flashes yellow with the step's message and #layout (everything
|
||||
// except the header) is modally disabled via the 'modally-disabled' class
|
||||
// (opacity + pointer-events:none - see style.css) so the pending step has
|
||||
// to be dealt with before anything else. The Sud is already blocked in
|
||||
// WAIT_USER server-side; clicking Confirm is what unblocks it.
|
||||
let userConfirmPending = false;
|
||||
const CONFIRM_DISABLE_IDS = ['btn-connect', 'btn-sud-start', 'btn-sud-pause', 'btn-sud-stop',
|
||||
'btn-sud-new', 'btn-sud-load', 'btn-sud-save'];
|
||||
|
||||
function setUserConfirmPending(pending, message = '') {
|
||||
if (pending === userConfirmPending) return;
|
||||
userConfirmPending = pending;
|
||||
const btn = document.getElementById('btn-user-confirm');
|
||||
document.getElementById('user-confirm-text').textContent = pending ? message : '';
|
||||
btn.disabled = !pending;
|
||||
btn.classList.toggle('confirm-pending', pending);
|
||||
document.getElementById('layout').classList.toggle('modally-disabled', pending);
|
||||
for (const id of CONFIRM_DISABLE_IDS) {
|
||||
document.getElementById(id).disabled = pending;
|
||||
}
|
||||
if (!pending) {
|
||||
// Start/Pause/Stop depend on sudState/sudEmpty/connected, not just
|
||||
// "was disabled for confirm" - let the usual logic pick the right
|
||||
// one back up rather than assuming all should simply re-enable.
|
||||
updateSudActions();
|
||||
}
|
||||
}
|
||||
|
||||
function downloadJson(doc) {
|
||||
@@ -744,7 +777,19 @@ function onSudChanged(msg) {
|
||||
// Processing Step first would set sudStepIndex before the schedule
|
||||
// exists, then Json would reset it to null (isNewSchedule=true on
|
||||
// first/reloaded connect) with no subsequent Step to restore it.
|
||||
const keys = ['Json', ...Object.keys(msg).filter(k => k !== 'Json')];
|
||||
//
|
||||
// UserMessage must likewise be processed before State: a step's
|
||||
// user_message is set once, when the step starts (components/sud.py's
|
||||
// _advance()), well before state later flips to WAIT_USER
|
||||
// (_finish_step()) - so UserMessage's key was inserted into
|
||||
// global_state earlier in the server's lifetime and would otherwise
|
||||
// precede State here too. A client connecting fresh mid-confirm gets
|
||||
// both bundled into this same replay message; processing State first
|
||||
// would check sudUserMessage before this very message has set it,
|
||||
// silently failing setUserConfirmPending()'s guard and leaving the
|
||||
// Confirm button looking merely disabled instead of pending - the
|
||||
// same race the old modal dialog had, just never noticed until now.
|
||||
const keys = ['Json', 'UserMessage', ...Object.keys(msg).filter(k => k !== 'Json' && k !== 'UserMessage')];
|
||||
for (const key of keys) {
|
||||
if (!(key in msg)) continue;
|
||||
if (key === 'Json') {
|
||||
@@ -780,10 +825,9 @@ function onSudChanged(msg) {
|
||||
sudState = newState;
|
||||
updateControlsEnabled();
|
||||
if (newState === WAIT_USER_STATE && prevState !== WAIT_USER_STATE && sudUserMessage) {
|
||||
showUserMessage(sudUserMessage);
|
||||
setUserConfirmPending(true, sudUserMessage);
|
||||
} else if (prevState === WAIT_USER_STATE && newState !== WAIT_USER_STATE) {
|
||||
const dlg = document.getElementById('sud-message-dialog');
|
||||
if (dlg.open) dlg.close();
|
||||
setUserConfirmPending(false);
|
||||
}
|
||||
updateStepPlates();
|
||||
updateSudActions();
|
||||
@@ -934,6 +978,11 @@ function connect() {
|
||||
for (const id of ['temp-soll', 'heatrate-soll', 'heater-power', 'stirrer-speed', 'closed-loop']) {
|
||||
document.getElementById(id).disabled = false;
|
||||
}
|
||||
// Nothing reported over a dead connection can be trusted anymore -
|
||||
// don't leave the page modally stuck on a pending confirm that'll
|
||||
// never resolve now (mirrors client/brewpi_gui.py's disconnect
|
||||
// handling).
|
||||
setUserConfirmPending(false);
|
||||
setConnected(false);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
@@ -1042,8 +1091,7 @@ document.getElementById('btn-sud-pause').addEventListener('click', () => {
|
||||
document.getElementById('btn-sud-stop').addEventListener('click', () => {
|
||||
sendMsg('Sud', {Stop: true});
|
||||
});
|
||||
document.getElementById('sud-message-dialog').addEventListener('cancel', e => e.preventDefault());
|
||||
document.getElementById('sud-message-ok').addEventListener('click', () => {
|
||||
document.getElementById('btn-user-confirm').addEventListener('click', () => {
|
||||
sendMsg('Sud', {Confirm: true});
|
||||
});
|
||||
|
||||
|
||||
+10
-11
@@ -17,10 +17,16 @@
|
||||
<button id="btn-sud-start" class="transport-btn transport-start" title="Start / Resume">▶</button>
|
||||
<button id="btn-sud-pause" class="transport-btn transport-pause" title="Pause">▮▮</button>
|
||||
<button id="btn-sud-stop" class="transport-btn transport-stop" title="Stop">■</button>
|
||||
<span id="header-countdown" class="hidden">
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">total</span><span id="hdr-total-remaining">-:--:--</span></span>
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">ramp</span><span id="hdr-step-remaining">-:--:--</span></span>
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">hold</span><span id="hdr-hold-remaining">-:--:--</span></span>
|
||||
<span id="countdown-confirm-col">
|
||||
<span id="header-countdown" class="hidden">
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">total</span><span id="hdr-total-remaining">-:--:--</span></span>
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">ramp</span><span id="hdr-step-remaining">-:--:--</span></span>
|
||||
<span class="hdr-cdown-row"><span class="hdr-cdown-lbl">hold</span><span id="hdr-hold-remaining">-:--:--</span></span>
|
||||
</span>
|
||||
<div id="user-confirm-row">
|
||||
<span id="user-confirm-text"></span>
|
||||
<button id="btn-user-confirm" type="button" disabled>Confirm</button>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -286,13 +292,6 @@
|
||||
|
||||
</main>
|
||||
|
||||
<dialog id="sud-message-dialog">
|
||||
<p id="sud-message-text"></p>
|
||||
<form>
|
||||
<button id="sud-message-ok" type="button">OK</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<footer>
|
||||
<div id="sud-status-line"></div>
|
||||
<div id="env-status-line"></div>
|
||||
|
||||
+53
-14
@@ -11,6 +11,7 @@
|
||||
--orange: #ffa726;
|
||||
--orange-dim: #6d3b00;
|
||||
--red: #f44336;
|
||||
--yellow: #ffeb3b;
|
||||
--led-off: #444444;
|
||||
--led-green: var(--green);
|
||||
--led-green-dim: var(--green-dim);
|
||||
@@ -80,6 +81,58 @@ header#connection-bar {
|
||||
}
|
||||
#host { width: 18em; }
|
||||
|
||||
/* --- User-confirm control (replaces a modal OK dialog) --- */
|
||||
|
||||
/* Sits directly under the hold-time row of #header-countdown (the two
|
||||
share this column so #header-countdown's own 'hidden' toggle doesn't
|
||||
hide the confirm row along with it - a WAIT_USER confirm can happen
|
||||
before the countdown ever becomes visible). max-width + wrapping keeps
|
||||
a long message from overflowing off-screen on an iPad's tighter
|
||||
viewport (~768-834 CSS px in portrait) instead of just widening the
|
||||
whole header sideways. */
|
||||
#countdown-confirm-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3em;
|
||||
}
|
||||
#user-confirm-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
max-width: min(60vw, 20em);
|
||||
}
|
||||
#user-confirm-text {
|
||||
color: var(--yellow);
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
#btn-user-confirm {
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#btn-user-confirm.confirm-pending {
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
animation: confirm-flash 1s step-start infinite;
|
||||
}
|
||||
@keyframes confirm-flash {
|
||||
0%, 49% { background: var(--surface-2); }
|
||||
50%, 100% { background: var(--yellow); }
|
||||
}
|
||||
|
||||
/* Modally disabled while a user-confirm is pending - #user-confirm-row
|
||||
(and its Confirm button) stays outside #layout so it's untouched. */
|
||||
#layout.modally-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-connected { color: var(--green); font-weight: bold; }
|
||||
.status-disconnected { color: var(--text-muted); }
|
||||
|
||||
@@ -392,20 +445,6 @@ header#connection-bar {
|
||||
}
|
||||
.step-plate .span2 { grid-column: span 2; }
|
||||
|
||||
/* --- Message dialog --- */
|
||||
|
||||
#sud-message-dialog {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.2em 1.6em;
|
||||
max-width: 24em;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.7);
|
||||
}
|
||||
#sud-message-dialog::backdrop { background: rgba(0,0,0,0.65); }
|
||||
#sud-message-dialog form { margin-top: 1em; text-align: right; }
|
||||
|
||||
/* --- Footer --- */
|
||||
|
||||
footer {
|
||||
|
||||
Reference in New Issue
Block a user