feat(p1am): heater bring-up + selectable 4-source thermocouple input - #3959
feat(p1am): heater bring-up + selectable 4-source thermocouple input#3959dieterolson wants to merge 69 commits into
Conversation
…tput module A P1-08TD2 (8-pt 12-24 VDC sourcing output) was added in slot 3. Drive the heater relay from its channel 1 instead of the 3.3 V GPIO (D2) — a real 24 VDC sourcing output that energizes a 24 V relay coil directly, no transistor/SSR driver needed. The temperature controller still commands it via Modbus coil 2 and the safety interlock forces it OFF on any trip; the output is forced OFF at boot. Also publish the signed-on backplane module count to TAG_26 so it can be read over Modbus (no serial needed) to confirm the module signed on — analog + thermocouple + DO = 3. Verified on hardware: TAG_26=3 (P1-08TD2 detected), thermocouple still reads ~26 C, and arming the heater drives coil 2 -> the DO channel ON (relay_on=True). Compiles for P1AM-100 (22% flash). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a shared axis model (lib/trendAxis.ts: AxisRange + resolveRange + axisTicks) and a reusable TrendAxisControls component, then wire them into every trend plot so the operator can rescale any chart the same way: - TempTrend (Heater Controls) — was fixed 0..full-scale °C - SignalDiagnostics — was fixed 0..5 V - PowerSupplyTrend — was fixed 0..100 % of full scale - TrendChart (Trends tab) — adds manual min/max alongside the existing auto-scale + Y-Zoom "Auto Y" fits the visible samples with ~8% headroom; manual mode pins the operator's min/max. This lets a small signal be zoomed in — e.g. watching a 24->100 °C heat-up that was previously squashed against a 1400 °C axis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a shared time-window model (lib/trendTime.ts: presets 30s/1m/2m/5m, windowSamples, downsample) + a reusable TrendTimeControls button row, and wire a selectable window into the simple SVG trends: - TempTrend (Heater Controls), SignalDiagnostics, PowerSupplyTrend — were fixed ~30 s; now 30 s / 1 m / 2 m / 5 m - TrendChart (Trends tab) — adds a 5 m preset alongside its existing buttons To feed the longer windows the rolling buffers grow to 5 min @ 10 Hz (MAX_TREND_SAMPLES = 3000): the telemetry-stream history cap, the PowerSupply buffer, and the Temperature buffer. Each plot downsamples to <=600 drawn points so a 5-min window stays cheap to render on the Pi. Pairs with the adjustable Y-axis from the previous commit so every trend now has full X + Y axis control. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Window and scale the trends by real wall-clock time instead of assuming a
fixed 10 Hz rate. The Pi polls below 10 Hz under load, so a "5 min" window
actually spanned ~5m50s and skewed every time calculation. Each sample is now
timestamped at ingest and the trends slice/scale from those timestamps.
Shared, reusable, unit-tested pieces (DRY / DbC / LOD):
- lib/trendTime.ts: min/hr units, toSeconds/clampWindow, windowStartIndex
(binary search on timestamps), elapsedSeconds, timeAxisTicks, generic
downsample — 17 tests
- lib/curveFit.ts: extensible fit-method registry + linear least-squares
(slope/intercept, R², equation); fitSeries() guards degenerate input — 10 tests
- TrendTimeControls (value text box + min/hr dropdown — seconds removed per
request), TrendFitControls (method dropdown), TrendPlotOverlays
(TrendTimeAxis + TrendFitOverlay)
Wiring:
- useTelemetryStream exposes historyTimes alongside history
- Temperature + PowerSupply buffers carry per-sample timestamps; buffers
deepened to 1 h
- all four trends get a visible X-axis ("-5m" … "now") + the value+unit window
- Temperature plot adds a linear fit overlay + equation (heating rate °C/min)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…window change Changing the time window now loads the matching history from the backend instead of only growing from "now". A new useTrendBackfill hook fetches the last `windowSeconds` of the tag from /api/trends whenever the window (or tag/scale) changes, and the temperature plot merges that backfilled history (anything older than the live buffer) ahead of the live samples — so widening to e.g. 30 min immediately shows the past 30 min and the X axis spans it. Timezone handling: the historian stores naive-UTC timestamps, so request bounds are sent zoneless and responses are parsed as UTC (not browser-local) to align with the Date.now()-stamped live buffer. Pure helpers (naiveUtcIso / parseHistorianTs) are unit-tested. The hook is generic (tag id + scale), so the other trends can adopt the same backfill next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the on/off heater controller tunable from the HMI so the operator can tighten regulation and cap how fast the relay cycles: - deadband_c is the ± half-band around setpoint — smaller = tighter control (relabeled in the UI to make the half-band explicit) - new min_on_time_s / min_off_time_s enforce an anti-short-cycle dwell: after the relay switches it's held at least that long before an opposite demand is honored, so a tight band can't chatter the relay. 0 disables (default). Controller: tick() now consults a monotonic clock (supplied by the scan loop) to measure the dwell. Safety paths (E-stop / trip / permissive-off / HH cutoff) still force the relay off the same tick, bypassing the dwell. Defaults of 0 s preserve the previous behavior exactly; status() + the config API expose both fields. 8 new unit tests cover the dwell timing and the safety bypass. HMI: Min ON / Min OFF time fields added to the Heater Controls config grid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Data is already captured continuously by the historian; this makes exporting it one click from anywhere in the GUI instead of buried in the sidebar with manual tag entry. - lib/dataExport.ts: a shared, pure URL/range builder — resolveRange (15m / 1h / 6h / today / all), formatTagIds (trim+dedupe), buildExportUrl. DbC guards on empty tag sets and inverted ranges. 8 unit tests. - ExportButton: a reusable [range ▾][Export] control any panel drops in, pre-scoped to the tags it shows — no typing tag ids. - Wired into Heater Controls (temperature tag), Signal Diagnostics (raw 0–5 V tags 20–25) and Power Supply (current/voltage tags 12/13). - CsvExporter (sidebar, advanced/manual export) refactored onto the shared builder so there's a single export code path (DRY). The Power Supply header's old button is relabeled "Capture" since it opens the capture/historian panel, distinct from the new CSV export. Backend /api/export was already present and verified (streams CSV for a tag set over a time range). 75 frontend tests pass; tsc + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…files) The historian logged every scan under a 2 GB cap, so the DB ballooned (~948 MB in two days). Decouple persistence rate from poll rate and make it tunable with a sensible default. Backend: - CaptureThrottle (data_capture.py): rate-limits historian writes to one per `interval_s` (injected clock; DbC on the setter). Wired into the poll loop via the existing `log_scan` seam, so the control/stream loop is untouched — only persistence is decimated. Unit-tested with a fake clock. - settings: new `capture_interval_s` (default 5 s, env P1AM_CAPTURE_INTERVAL_S); historian_max_bytes default lowered 2 GB -> 1 GB. - GET/PUT /api/capture/config (admin-gated PUT) read/set the interval at runtime; CaptureConfig model bounded 0–3600 s. HMI (DataCapturePanel): - "Sampling rate" card: presets (1/2/5/10/30 s) + custom interval input + Apply, showing the current rate. Note that live trends still update every scan; only the stored history is decimated. At 5 s the DB grows ~5x slower; the operator can go finer for an active run or coarser when idle. ruff/mypy/format clean; backend capture + 75 frontend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estart) Run the control system as two systemd units so it survives reboots, power loss, and session/terminal teardown instead of needing a manual run_pi.sh relaunch: - p1am-backend — uvicorn (single Modbus master) on 127.0.0.1:8000 - p1am-frontend — Vite HMI on :3002 install-services.sh generates + installs both units (paths/user detected from the checkout, so it's portable), enables them for boot, and (re)starts them. Restart=always + RestartSec=3 auto-recovers a crash (validated: kill -9 the backend, systemd respawns it). README documents management + the dev-vs-prod HMI note. The host static IP is configured separately and left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reen toggle Adds a second thermocouple (type R on its own THM channel) and a toggle so the operator picks which TC drives the heater per experiment; all control (setpoint band, HH cutoff, trends, CSV export) follows the selection. Backend (DbC / LOD / DRY): - temperature_models: new ThermocoupleChannel + TcType; TemperatureConfig holds type_k + type_r channels + active_tc_type. temp_tag / temp_full_scale_c / active_tc_label are computed (read-only) from the active channel, so every reader keeps one stable accessor. Invariants check the active channel's scale. - TemperatureController.set_active_tc_type() switches + re-clamps the limits to the new channel (re-validated); status reports active_tc_type / label. - POST /api/temperature/tc_type (admin-gated) toggles the active TC. - +30 tests; controller and models at 100% line coverage, integration 97%. Firmware: - P1-04THM channel 2 (TC1 -> TAG_1) set to type R; channel 1 (TAG_0) stays type K. Named type-code constants (kTcTypeK=0x01 / kTcTypeR=0x03). The module scales every channel over the same 1400 C full scale, so K and R differ only by tag. NOTE: flash + verify the R channel against a known temperature before relying on it. HMI: - Heater Controls gains a Type K / Type R segmented toggle + an active-TC readout; the config grid now edits each channel's tag + full scale. The temp trend and CSV-export tag follow the active channel automatically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h mode) run_pi.sh enables dev-no-auth for bench use, but the systemd unit omitted it, so every admin-gated control POST (permissive, setpoint, tc_type, capture config, estop clear) returned 503 "credential not configured" once the services took over. Mirror run_pi.sh's bench default in the generated unit; documented how to switch to real API keys if the HMI is ever exposed beyond the bench. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…2=R)
The P1-04THM module channels are 1-indexed (1-4) and the broker maps channel N
to TAG_(N-1). The config was already correct (ch1=K -> TAG_0, ch2=R -> TAG_1);
this just fixes the misleading 0-indexed "TC0/TC1" naming in the firmware comment
and labels the HMI channels by physical number ("Type K (Ch 1)" / "Type R
(Ch 2)") so the wiring-to-tag mapping is unambiguous.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Live temperature trend (and the other trends) placed each point at its array
INDEX (idx/(n-1)) across the width, while the X axis was drawn from real elapsed
time. That's only consistent when samples are evenly spaced — but once historian
backfill (sparse, ~5 s) is merged with live samples (dense, sub-second), the
index position drifts hard against the time axis, so the trace appeared to
scroll "way too fast".
Fix: a shared, time-accurate path builder (lib/trendTime: timeToX +
timeSeriesPath) places every point by its real timestamp on a [t0,t1] axis, so
the trace matches the time labels regardless of sample spacing or poll jitter.
- TempTrend: data path + linear-fit overlay now time-positioned (TrendFitOverlay
takes per-point {t, x} + [t0,t1]).
- PowerSupplyTrend + SignalDiagnostics migrated to the same helper (DRY); each
downsamples rows + timestamps in lockstep so points keep their real time.
- timeToX / timeSeriesPath are pure, DbC-guarded (empty on <2 points or a
degenerate range), and unit-tested, including an explicit "positions by time,
not index" regression test. 81 frontend tests pass; tsc + build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 60 min The trend X axis spanned the data's *actual* range (elapsedSeconds), so changing the window didn't rescale the axis until the buffer filled — it looked "stuck on a minute scale". Switch to a FIXED window [latest - windowSeconds, latest]: the axis always spans exactly the selected window, so changing it rescales the axis immediately (and a partly-filled buffer just leaves the left side empty). - lib/trendTime: fixedWindowRange() (unit-tested); restore seconds to the window dropdown (SELECTABLE_TIME_UNITS = s/m/h); windowUnit shows 3600 s as "60 min". - TempTrend / PowerSupplyTrend / SignalDiagnostics: X-axis span = windowSeconds with the range from fixedWindowRange; default window raised to 60 minutes. 83 frontend tests pass; tsc + build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…htweight toggle Three navigation / ops improvements: - Freeze panes: the tab bar is now sticky just below the (already sticky) header, so the active tab stays visible while scrolling a panel. The header height is measured via ResizeObserver so the offset stays correct. - Collapsible sections: a reusable CollapsibleSection wraps the major Heater Controls + Power Supply cards (thermocouple, trend, setpoint, telemetry, live signals) so the operator can minimize what they aren't using. - Performance mode: a global header toggle switches the PLC scan cadence between 'performance' (fast) and 'lightweight' (slow, default 2 s) to cut CPU and — mainly — how often the browser re-renders the live trends. Backend PerformanceController (runtime, DbC; models split out for the StrEnum/mypy pattern) is read by the scan loop; GET/PUT /api/performance. 8 unit tests. Note: profiling showed the backend is only ~2% CPU; the Pi load (7.4 on 4 cores) is dominated by VS Code, the VNC desktop, and Firefox. Lightweight mode helps the HMI-render share, not the editor/VNC load. 83 frontend tests + backend perf tests pass; tsc / build / ruff / mypy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… hidden Conserve Pi CPU by polling the PLC slowly unless an operator is actively watching the HMI. - Backend: the global PerformanceController now defaults to lightweight, so an unattended backend stays easy on the Pi until performance mode is explicitly requested. - Frontend: the perf toggle is now the operator's preference for when the tab is visible (lightweight by default). A Page Visibility listener forces lightweight whenever the tab is hidden and restores the preference when it is shown again — fast polling only happens when someone is looking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…zation
Add a web-native "Data Explorer" tab to the HMI, reimplementing the core of the
desktop PyQt6 Data Processor so operators can analyze captured data without
leaving the browser.
Backend (FastAPI, numpy-only — no scipy/sklearn so the Pi stays lean; router
degrades gracefully if numpy is ever absent):
- New /api/explorer/* router: signals, dataset, statistics, correlation,
spectrum, trendline, pca, histogram, export.
- Pure-numpy kernels with full Design-by-Contract validation:
- signals: moving-average, exponential, median, gaussian, Savitzky-Golay,
Hampel, z-score, zero-phase FFT low/high/band-pass, integrate, differentiate,
time-bin resample (mean/median/first/last/min/max/sum).
- expression: safe AST evaluator for derived columns (sin/cos/sqrt/abs/log/
exp/min/max/mean/clip, pi, e; no attribute/subscript/call escapes).
- stats: describe, Pearson/Spearman correlation, cross-correlation, FFT/Welch
spectrum, linear/poly/exp/power trendlines (R² in original space), PCA via SVD,
histogram.
- Dataset service aligns historian tags onto a common index, runs the pipeline
(resample → filters → derived → trim → downsample), maps NaN<->null at the JSON
boundary. Sources: historian query OR inline (browser-parsed CSV).
- 177 new backend tests; numpy added to the Dockerfile.
Frontend (React + Vite, dependency-free SVG plots):
- DataExplorer tab: pick source (historian tags + time presets, or CSV upload),
build a dataset, then visualize (line/scatter/histogram with axis styling,
trendline overlay, PNG/SVG export), plus Statistics, Correlation heatmap,
Spectral, and PCA panels, dataset export (CSV/JSON), and saved sessions.
- Zod-validated /api/explorer client; forwardRef SVG plot components
(PlotFrame/LinePlot/ScatterPlot/Histogram/Heatmap/SpectrumPlot) with a shared
projection module; lib/explorer helpers (CSV parse, scales, palette, download).
- 76 new frontend tests.
Verification: backend 655 passed; frontend 159 passed; tsc + eslint + vite build
clean; ruff/ruff-format/mypy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A multi-agent adversarial review (each finding independently verified) surfaced 8 real defects in the new Data Explorer; all are fixed with regression tests. Backend: - resample_series: cap the bin grid (5M) and vectorize the per-bin aggregation, so a tiny interval over a wide span returns 400 instead of OOM-killing the Pi (was: unbounded allocation + O(n_bins·n_samples) Python loop). - expression evaluator: reject 3-arg min()/max() (a 3rd positional was numpy's out= and mutated the caller's column) and always copy coerced inputs; strict per-function arity (clip=3, mean=1, unary=1); numpy ufuncs for binops so a huge exponent / divide-by-zero yields inf/nan instead of escaping as OverflowError. - export: eagerly validate the index (non-finite / out-of-range epoch-ms -> 400) since the CSV stream can't error mid-body; _epoch_ms_to_iso is now crash-safe. - historian build: bound the materialized matrix (index×tags) and cap tag count (<=64) so a wide range × many tags is a 400, not an OOM. Frontend: - PNG/SVG plot export: bake the active theme's CSS variables into the standalone SVG so exported charts keep their colors (var(--x) had no :root to resolve to). - CSV upload: surface parse failures to the operator (were silently swallowed) and reset the file input so the same file can be re-selected. Verification: backend 675 passed (+20 regression tests); frontend 159+ passed (+5 download tests); ruff/format/mypy + tsc/eslint/build clean; live guards confirmed (resample/export/tag-cap all 400/422) and full E2E still green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tend code-split
Data-driven hardening pass from a 4-agent audit (correctness, backend perf,
frontend perf, coverage). Measured verdict: the backend is I/O-bound at <1% CPU
with the Rust SCADA kernel already active — more Rust is NOT warranted; every
real win is SQLite, the event loop, or the frontend.
Backend performance (measured):
- Composite (tag_name, timestamp) historian index: trend/export/explorer reads
go from an index-scan + temp-B-tree sort (~3.9 s on the 6.2 M-row DB) to a
pure indexed range scan (~0.58 s, ~7x). Idempotent startup migration handles
the existing DB; the redundant single-column index is dropped.
- WAL: add PRAGMA journal_size_limit (64 MiB) + a one-off and periodic
wal_checkpoint(TRUNCATE) to reclaim a WAL that had bloated to ~394 MB.
- Move the heavy DB/compute routes off the asyncio event loop: the Data Explorer
handlers and /api/{trends,export,capture/status,capture/clear,events,plant,
ladder-explorer} are now sync `def` (FastAPI threadpool), so a slow query,
export, VACUUM, or PCA can no longer freeze the 10 Hz control loop + E-stop.
Safety hardening (TDD):
- Open-thermocouple runaway: a non-finite feedback while RUNNING now latches a
TC_FAULT trip (fail-safe) instead of coercing to 0 C and calling for heat.
- HH over-temp uses >= in the power-supply controller (matches the temperature
controller) so a reading pinned exactly at the limit trips.
- A latched E-stop now rejects config / TC-type mutation (one-way kill),
consistent with setpoint/permissive.
- plc_driver default "simulated" now matches the factory ("simulator"); the
factory accepts both spellings — no more spurious "unknown driver" boot warning.
Frontend (Pi cold-load):
- Code-split: main app chunk 409 kB -> 141 kB, with react/zod/icons in cached
vendor chunks and the Data Explorer (42 kB) + project/ladder/hierarchy tabs
lazy-loaded (React.lazy + Suspense).
- Serve the minified production build via `vite preview` (config + systemd unit)
instead of the dev server — no HMR/transpile overhead on the Pi.
Verification: backend 687 passed (+12 new: DB migration/pragmas, sensor-fault
trip, E-stop config lock, HH boundary); frontend tsc/eslint/vitest(164)/build
clean; ruff/format/mypy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- build_dataset now asserts every column stays aligned to the index before the trim/downsample mask, so a filter/derived/ragged-resample length mismatch surfaces as a clean 400 instead of an IndexError-500 deep in the pipeline. - Bound concurrent /dataset builds with a semaphore (3): a burst of large-range requests (each up to ~160 MB) now sheds load with 503 rather than risking an OOM-kill of the SCADA core. Complements the earlier per-request cell cap. Tests: +3 (alignment guard accept/reject, 503 load-shed). Backend 690 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te tuning, safety DRY base, E-stop reconnect defense, opt-in read-auth, security tests Implemented the full hardening backlog (parallel agents, integrated + verified as one). SQLite stays — it is the correct single-writer embedded store; tuned rather than replaced. Frontend (Pi render CPU + memory): - useTelemetryStream: non-live fields (alarms/alicats/tagsDict/power/temp) are now ref-stable (functional updaters return prev on no-change) so memoized consumers bail out; history buffer is a bounded tail-slice (no O(n) [...prev]+shift) and MAX_HISTORY dropped 36000->6000 (10 min @10hz; longer windows come from backfill). - React.memo on TabBar/AlarmsHeader/SignalDiagnostics/RoutingMatrix/EventLogView/ InterlocksPanel/PowerSupply/Temperature; useCallback the callbacks App passes them (triggerNotification, select/ack/tab handlers) so memo actually engages. - TrendChart: downsample(RENDER_MAX_POINTS) before the extent scan + SVG path build (full-res kept only for CSV); memoized extent + path strings; freeze now snapshots synchronously in the click handler (fixes the stale-closure). SQLite tuning (read throughput, durability unchanged): - Per-connection mmap_size=256MiB, cache_size=64MiB, temp_store=MEMORY; a best-effort PRAGMA optimize at startup after the index migration. Safety-controller DRY (M10): - Extracted the shared safety scaffolding (IDLE/ARMED/RUNNING/TRIPPED, one-way E-stop latch, permissive/arm, trip latch+ack, _safe_finite, force-off predicate) into a generic SafetyStateMachine base; both controllers subclass it. Behavior-preserving — every existing safety suite passes unchanged. E-stop / de-energize defense-in-depth (H4/H5/H6): - Reconnect (_connect_once) and each scan (_poll_once) re-engage the process-local controller latches + write-seam interlocks BEFORE any service poll can command an output; main.py now passes the temperature service to _connect_once too. - Service write seams force an energizing command to 0 when the E-stop flag is set (de-energize never blocked); a failed de-energize retries once and latches a comms-failure flag for escalation. Opt-in read-auth (H2): - New P1AM_REQUIRE_READ_AUTH (default False -> current public read behavior). When on (and DEV_NO_AUTH off) the read surface (trends/export/snapshot/events/plant/ladder + /api/explorer/*) requires a valid API key, via a require_read_auth dependency injected into the explorer router. Security tests: auth_config authorization branches (503/401/403/dev-bypass) and project_import zip-slip / zip-bomb / oversized-member / bad-zip defenses. Verification: backend 777 passed (+87), frontend tsc/eslint(0)/vitest 171/build(main 142kB) clean, ruff/format/mypy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give operators a simple, double-clickable way into the HMI — no VS Code or terminal. The backend + frontend already run as boot-persistent systemd services (which own the PLC connection), so the launcher just gets the operator to the running program. - launch-hmi.sh: ensures both services are up (best-effort passwordless-sudo start of a stopped one), waits for the HMI on http://localhost:3002 (the frontend rebuilds on a fresh boot, so it polls up to 90 s), then opens it in a clean maximized Chromium app window (falls back to firefox/xdg-open). Never blocks on the PLC — the HMI shows the live link state. Env toggles: P1AM_KIOSK=1 (full-screen), P1AM_LAUNCH_TIMEOUT, P1AM_LAUNCH_NO_BROWSER (test). Shows a zenity dialog on failure with how to check the services. - install-desktop-launcher.sh: installs a "P1AM Heater Control" icon on the Desktop + an applications-menu entry for the current user (no root), with detected absolute paths; marks the desktop copy trusted (gio). - p1am-hmi.svg: a themed thermometer/heat launcher icon. Verified on the Pi: installer creates both entries; headless readiness check returns READY; chromium/firefox/zenity/gio present; passwordless sudo works. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…int applies on Enter The "permissive" toggle was misleading to operators, and the setpoint needed a separate "Apply" click. Replaced with a plain Start/Stop model and immediate setpoint apply. - Start/Stop button (was PERMISSIVE ON/OFF): green "▶ START" when stopped, red "■ STOP" when started; disabled while tripped (acknowledge first). Start arms the heater and applies the shown target so it begins heating; Stop opens the relay immediately (with a confirm when energized). - Setpoint applies on Enter (and the ± steps) — removed the separate "Apply Setpoint" button, added an "Enter to apply" hint. While stopped, a typed target is staged and applied on Start (the server only honors a setpoint once started, so the client sends permissive→setpoint in order to avoid a rejected apply). - Reworded all operator-facing "permissive" language (labels, warnings, state hints, docstring) to Start/Stop / stopped-heating terms. Decision logic extracted to a pure, unit-tested module (lib/heaterControls.ts: startStopView, setpointOutcome) — TDD/LOD/DRY; the panel just renders the result and reuses two small POST helpers. Backend API unchanged. Verify: frontend tsc/eslint(0)/vitest 179 (+8)/build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etpoint, and hold-last-good on Modbus read hiccup Persistence (config_store.py + wiring): a durable SQLite key->JSON settings store recalls operator settings after a restart instead of resetting to defaults — alarm/interlock setpoints + PID (SCADA-authoritative: overlaid onto any PLC-connect read so a stale PLC can't clobber them), heater + power-supply config + last setpoint, historian capture rate, and performance mode. SAFETY: settings only — a restored controller comes back IDLE; the operator presses Start to resume to the recalled setpoint (nothing auto-energizes). Lifespan restore + save-on-change wired in main.py; services persist via an injected session_factory (None in tests = no-op). Heater setpoint: moved to a prominent card near the top of the panel (was a lower collapsed section) and pre-filled from the recalled last-session target so it's ready to Start. K-dropout FIX (poll_runtime): the poll loop fell back to the OFFLINE SIMULATOR's fabricated ~0 tag values whenever a real Modbus read momentarily failed (frequent under Pi CPU load) — showing as the type-K reading intermittently dropping to zero and feeding the control law a false "cold" (a heater-runaway contributor). Now a connected-but-failed read HOLDS the last good values; the simulator only drives tags when the PLC is genuinely offline. The K thermocouple itself is reliable; this makes the software reliable too. Verification: backend 30 targeted + 802 full tests pass; ruff/format/mypy clean; frontend tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oss-check trip
Hardens the heater controller against the type-R runaway failure mode, where a
dead controlling thermocouple read a fixed ~34 C ("cold") while the vessel was
really >790 C, so the on/off law called for heat with no over-temp protection
until HH.
Backend:
- TemperatureStatus gains type_k_temp_c / type_r_temp_c: the service now scales
BOTH thermocouples every scan (independent of which one controls) so the HMI
can display and plot both channels, and feeds the non-controlling one to the
controller as a safety reference.
- HH cutoff now trips on EITHER thermocouple: a stuck/dead controlling sensor
can no longer mask a real over-temperature — the healthy channel still trips.
- New debounced cross-check trip (TC_DISAGREE): while RUNNING, if the controlling
TC reads essentially cold (<100 C) while the other reads clearly hot (>=200 C)
for several consecutive scans, the controller latches TRIPPED and opens the
relay. Wide cold-vs-hot band + debounce so a legitimate inter-probe gradient or
a transient read never false-trips. This catches the stuck-sensor runaway fast,
well before HH, and also catches an operator switching control onto a dead-cold
TC while the vessel is hot.
tick() gains a keyword-only other_temp_c (default None); single-TC callers and
all existing tests are unaffected.
TDD: 15 new tests (both-TC HH, cross-check debounce/reset/startup/not-running/
good-sensor-controlling, service end-to-end incident reproduction). Full backend
suite 817 passing; ruff + format clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…FE foundation) Shared, pure, tested foundation for the heater HMI upgrades: - curveFit.ts gains pointsInLastWindow() (regress only the operator-chosen recent window), heatUpRateFromFit() (linear slope -> deg/min & deg/hr, x-unit aware), and formatHeatUpRate() (readout formatting). All pure + DbC-guarded; +19 tests. - TemperatureStatus (FE) gains optional type_k_temp_c / type_r_temp_c mirroring the new backend fields, so the selector can show both live readings and the trend can plot both channels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndation) Adds a shared EditableValue control so every operator-changeable number on the HMI can be edited in place with identical, safe behavior: - Reads like text; click (or focus+Enter) edits in place; Enter/blur commits; Escape cancels. - Pure decideCommit() (lib/editableValue.ts) parses, clamps to [min,max], and reports whether the value actually changed — so an invalid or no-op edit never calls onCommit. Fully unit-tested; DbC (rejects garbage, throws on inverted min/max range). - Accessible: labeled control, aria-invalid on bad input, keyboard-operable. +15 tests (8 pure logic, 7 render). This is the DRY foundation for making setpoints, cutoffs, and interlock limits inline-editable across the app. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eadout Heater HMI upgrades (all driven by the new backend type_k/r_temp_c fields): - Thermocouple selector now shows each channel's LIVE reading (K and R) so the operator can spot a dead/stuck sensor at a glance; the active channel is emphasized, the other dimmed but visible. Smooth K<->R switch unchanged (no stop/start). - Trend plots BOTH thermocouples at once (active solid/bold, other dimmed), with legend toggles for K, R, and a shaded "Heater ON" band (signal picker). Null readings break the line instead of drawing through a dead-sensor gap. Historian backfill routes into the matching channel. Setpoint + HH reference lines kept. - Curve fit gains an operator "Fit window (min)" input and a heat-up-rate readout box showing the ramp in °C/min AND °C/hr plus R², reusing the shared curveFit helpers (pointsInLastWindow / heatUpRateFromFit / formatHeatUpRate). New pure exported helpers formatTcReadout() + heatUpRateReadout() (co-located, DbC-guarded) with +8 unit tests. tsc + eslint clean; vitest 31 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…section The high-high cutoff shown in the live status strip is now click-to-edit via the shared EditableValue control, so the operator can change it right where it is displayed instead of opening the config editor. Commit routes through a small commitConfigField() helper that PUTs the single field via the existing /api/temperature/config endpoint; the server re-validates + clamps and returns the authoritative config, which the UI adopts. Bounded to [0, full_scale] and committed only on Enter/blur (invalid or unchanged edits never write). tsc + eslint clean; TemperatureControl + EditableValue suites (27 tests) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… strip Mirrors the heater HH-cutoff change for app-wide consistency: the "Limit" readout in the power-supply live status strip is now click-to-edit via the shared EditableValue control, committing through the same putConfig() path the dedicated clamp control uses (bounded [0.1, 100] %, server re-validates). Now every operator-changeable value shown in a live status strip is editable in place. tsc + eslint clean; full frontend suite 215 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's numpy stubs type np.hanning/hamming/blackman and np.sqrt as Any, so returning/assigning them under concrete annotations tripped no-any-return (np.asarray(dtype=float) didn't help — still Any in those stubs). Use typing.cast to force the concrete type; cast is a runtime no-op (behavior unchanged) and is never flagged unused, so it holds across mypy/numpy versions. ruff/format clean; data_explorer_stats tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a full LaTeX architecture report (docs/SYSTEM_ARCHITECTURE.tex) covering the hardware stack, PLC firmware, backend, on/off control law, safety architecture, HMI, data/historian path, deployment, and the thermocouple-noise findings. Add a top-level README.md (the missing current-architecture overview) tying the docs together, with the hardware/coil map, control + safety summary, run/deploy steps, and remote access (Pi Connect / VNC-over-Tailscale / SSH). Reconcile the existing docs with actual behavior: - firmware/README: Ch 2 is Type-R (not "all type-K"); add coils 1/2/3 (E-stop reset, heater relay P1-08TD2, THM burnout direction) to the register map. - deploy/README: p1am-frontend serves the production build via `vite preview` (not a hot-reloading dev server); add remote-access pointer. - USER_MANUAL: historian is throttled (not every scan); add remote access + architecture-report pointer. - BENCH_HANDOFF / PI_BRINGUP: stale-snapshot banners redirecting to current docs. - scripts/README: clarify these are Windows dev-PC helpers (the Pi uses NetworkManager). No code changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: OpenAI Codex <codex@openai.com>
…log)
Add signal-conditioned analog thermocouples as a selectable control source
alongside the existing P1-04THM card path. Each physical TC (K/R) can now be
read two ways, giving four operator-selectable sources chosen in the HMI the
same way K/R was before.
Design: a new orthogonal acquisition-path axis (TcPath = TC_CARD | ANALOG),
defaulting to TC_CARD so all existing configs/behavior/tests are unchanged.
Backend (TDD/DbC/LOD/DRY):
- temperature_models: TcPath enum; analog_k/analog_r channels (AI2/AI3 ->
TAG_14/15); active_tc_path; channel_for(type,path) as the single source of
truth; cross_check_channel (same-path other sensor for HH/cross-check).
- temperature_controller: set_active_source(type,path) with shared re-clamp;
set_active_tc_type kept as a backward-compatible shim.
- temperature_integration: per-tag deglitch filters; select active + same-path
cross-check partner; publish the active-path pair. /tc_type gains an optional
active_tc_path (defaults to TC_CARD).
Firmware (compiles at 22% flash; flash later — hardware not yet wired):
- SignalBroker: kNumInputs 6->8; read all 4 AI (route AI2/AI3 -> slots 6/7).
- P1AMHardware.ReadAnalogInput: allow channels 2-3; scale AI2/AI3 over the
conditioner's 4-20 mA live-zero span ((mA-4)*100/16), AI0/AI1 unchanged.
- firmware.ino: default bench routing AI2->TAG_14, AI3->TAG_15.
HMI (TDD): four-way source selector (TC Card / Analog × K/R) posting
{active_tc_type, active_tc_path}; source label + list extracted to
lib/tempSource (fast-refresh clean). Plots follow the active path.
Docs: firmware/README, SYSTEM_ARCHITECTURE.tex (new §Temperature source
selection), USER_MANUAL, top-level README, hardware.py routing comment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 588205a74c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| deglitch = self._filters.get(channel.tag) | ||
| if deglitch is None: | ||
| deglitch = ThermocoupleDeglitchFilter() | ||
| self._filters[channel.tag] = deglitch |
There was a problem hiding this comment.
Preserve analog loop-under-range faults
When the operator selects an analog source whose conditioners are already unpowered or disconnected, P1AMHardware.cpp:148-158 converts both under-range loops to valid zeroes and this newly created filter accepts its first zero as a good reading (thermocouple_filter.py:144-147). Subsequent zeroes never enter the hold/fault state, and with both analog channels at zero neither the same-path cross-check nor HH check trips, so a positive setpoint can command the heater indefinitely without valid temperature feedback. Preserve an under-range fault indication or reject an initial analog zero rather than treating it as a valid cold measurement.
Useful? React with 👍 / 👎.
| # ---- Register map (holding registers; see firmware/README.md) --------- | ||
| TAG_VALUE_BASE = 0 # tag values: TAG_i at (i*2, i*2+1) little-endian float | ||
| INPUT_ROUTING_BASE = 100 # channel -> tag id (slots 0-3 TC, 4-5 AI) | ||
| INPUT_ROUTING_BASE = 100 # channel -> tag id (slots 0-3 TC, 4-7 AI0-3) |
There was a problem hiding this comment.
Expose all eight input-routing slots
Although the firmware/register contract now has eight inputs, ModbusManager.read_routing() still reads count=6, default_routing_config() still creates six entries, and RoutingMatrix.INPUT_LABELS still renders only six rows. Consequently AI2/AI3 cannot be inspected or remapped through the API/HMI, and a deployment cannot initialize their routing on a bench whose stored slots 6-7 are not already the new defaults. Add an input-count constant and use eight slots throughout instead of retaining the hard-coded sixes.
AGENTS.md reference: AGENTS.md:L178-L182
Useful? React with 👍 / 👎.
| const iPts = bridgeTimedSeries(down.map((s) => ({ t: s.t, v: toPct(s.i, currentFullScale) }))); | ||
| const vPts = bridgeTimedSeries(down.map((s) => ({ t: s.t, v: toPct(s.v, voltageFullScale) }))); | ||
| const pPts = bridgeTimedSeries(down.map((s) => ({ t: s.t, v: toPct(s.p, powerFullScale) }))); |
There was a problem hiding this comment.
Keep real zero intervals in process trends
These unconditional calls cannot distinguish a dropped read from a real zero: if current, voltage, or power genuinely reaches zero for one or two rendered samples—such as a brief output shutdown bracketed by nonzero operation—the helper interpolates the shutdown away. Because bridging occurs after downsample(), even a longer real zero interval can collapse to two samples in a wide viewport, causing the chart and auto-range to misrepresent the experiment; only bridge samples carrying an explicit invalid/dropout signal rather than every near-zero run.
Useful? React with 👍 / 👎.
| # Publish the ACTIVE path's K and R pair for the HMI (the plotted traces | ||
| # follow the selected path). active/other map onto K/R by which type is | ||
| # controlling. | ||
| active_is_k = cfg.active_tc_type == TcType.TYPE_K | ||
| k_sample = active_sample if active_is_k else other_sample | ||
| r_sample = other_sample if active_is_k else active_sample | ||
| self._last_type_k_c = k_sample.value_c | ||
| self._last_type_r_c = r_sample.value_c |
There was a problem hiding this comment.
Separate trend history when the acquisition path changes
When active_tc_path switches, these published K/R values immediately change from the TC-card sensors to the analog-conditioned sensors, but TemperatureControl keeps appending them to the same path-less TempSample buffer without clearing or partitioning it. The displayed traces and heatUpRateReadout() therefore fit measurements from two different acquisition paths as one continuous series after a source switch, producing misleading experimental trends and ramp rates; retain the path per sample or start a new series on path changes.
Useful? React with 👍 / 👎.
| <label className="input-label">Tags (comma-separated)</label> | ||
| <input | ||
| id="csv-tags" | ||
| type="text" |
There was a problem hiding this comment.
Restore programmatic labels for CSV inputs
Removing each input's id and the corresponding htmlFor leaves these sibling <label> elements unassociated with their controls, so screen readers no longer announce the Tags, Start Time, or End Time labels when the fields receive focus and clicking a label no longer focuses its input. Restore the label/input associations or add equivalent accessible names.
AGENTS.md reference: AGENTS.md:L299-L299
Useful? React with 👍 / 👎.
|
Consolidated into master PR #3951. |
Pull request was closed
The analog thermocouple path uses AutomationDirect FC-T1 signal conditioners whose 4-20 mA endpoints are NOT 0..full-scale: Type-K spans -150..1372 C and Type-R spans 65..1768 C. The old scaling assumed 4 mA = 0 C, so conditioned readings were wrong (e.g. Type-R at 4 mA read 0 C instead of its 65 C floor). - ThermocoupleChannel gains range_min_c (deg C at 0 %/4 mA; default 0 keeps TC-card channels zero-based) + a single scale_percent() seam (DRY) doing the affine map range_min_c + pct/100*(full_scale_c - range_min_c), validated for a positive span. - analog_k/analog_r default to the FC-T1 factory ranges (-150..1372, 65..1768). - Integration _temp_from_channel / _temp_from_tags use scale_percent. - Frontend ThermocoupleChannel gains optional range_min_c. Verified live on the bench: with both conditioners at their 4 mA live-zero, Analog K reads -150 C and Analog R reads 65 C (their range floors), as expected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Deliberately NOT included in the consolidated P1AM safety batch (#4448). Left open for rework — please do not merge as-is. The other eleven PRs in that batch merged. This one is held back because the new analog thermocouple path fails LOW on a broken loop, and nothing in the PR catches it. On a heater, reading cold is the dangerous direction — the controller responds by adding heat. The failure
percent = (mA - 4.0f) * (100.0f / 16.0f);
...
if (percent < 0.0f) { percent = 0.0f; }Combined with the affine defaults from the last commit (
Net: unpowered or broken conditioners with an analog source selected leaves the heater full-on with The tests encode this as intended behaviour, which is why it needs rework rather than a follow-up:
Second blocker, independent of the above
// Bumped from 0xDC51 when InterlockConfigData grew from 2 to 4 limits.
// Configs written by older firmware are silently rejected and the unit
// boots with defaults instead of garbling the new wider struct.
static const int kMagic = 0xDC52;A retained config passes the magic check and loads misaligned: Also needs resolving before merge
What is genuinely good here and worth keepingThe TC-card path is a real improvement and the review found no defect in it: Suggested split: land the heater/TC-card work and the Stop fix on their own; hold the analog source behind the firmware under-range sentinel (e.g. |
|
Closing — excluded from the P1AM SCADA consolidation (#4448) on safety grounds, not superseded. The selectable analog thermocouple path fails LOW. On a broken loop it reports roughly −150 °C All three mitigations claimed in the PR were checked and none is functional. Most tellingly, the PR's Separately, it widens Why closed rather than left open: auto-merge (SQUASH) was armed on this PR, and in this repo The heater bring-up work itself is wanted — please re-cut it on a thermocouple path that fails safe. Context: part of the 2026-08-13 consolidation drive. The other 11 P1AM PRs are consolidated in #4448. |
Lands the P1AM heater bring-up work plus the new selectable 4-source thermocouple input (K/R × TC-card/analog-conditioner).
Re-opened on a fresh branch because the prior PR (#3835) was repeatedly auto-closed by
Jules-Cleaner.yml(it closes any PR number listed in consolidation #3951's body — but #3951 contains 0 p1am files, so the work never actually landed onmain).What's here
TAG_14/15), selectable in the HMI exactly like the old K/R toggle. OrthogonalTcPathaxis defaults to the card path, so all existing behavior/config/tests are unchanged.Verification
arduino-cli compileclean.🤖 Generated with Claude Code