Skip to content

feat(p1am): heater bring-up + selectable 4-source thermocouple input - #3959

Closed
dieterolson wants to merge 69 commits into
mainfrom
feat/p1am-analog-tc-source
Closed

feat(p1am): heater bring-up + selectable 4-source thermocouple input#3959
dieterolson wants to merge 69 commits into
mainfrom
feat/p1am-analog-tc-source

Conversation

@dieterolson

Copy link
Copy Markdown
Collaborator

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 on main).

What's here

  • 4-source TC selection — each thermocouple (K/R) readable via the P1-04THM card or a 4-20 mA signal conditioner on analog inputs AI2/AI3 (TAG_14/15), selectable in the HMI exactly like the old K/R toggle. Orthogonal TcPath axis defaults to the card path, so all existing behavior/config/tests are unchanged.
  • Backend (model/controller/integration/endpoint), firmware (compiles at 22% flash), HMI (4-way selector), and docs.
  • Plus the earlier bring-up: Stop-safety fix, plot dropout bridging, diagnostics feed, setpoint-startup fix, architecture report + READMEs.

Verification

  • Backend 908 tests, frontend 417 tests; ruff/format/eslint/tsc clean; 500-LOC budget clean; firmware arduino-cli compile clean.

🤖 Generated with Claude Code

dieterolson and others added 30 commits June 23, 2026 10:17
…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>
dieterolson and others added 10 commits July 22, 2026 12:36
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>
@dieterolson
dieterolson enabled auto-merge (squash) July 28, 2026 20:15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +434 to +437
deglitch = self._filters.get(channel.tag)
if deglitch is None:
deglitch = ThermocoupleDeglitchFilter()
self._filters[channel.tag] = deglitch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +143 to +145
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) })));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +143 to 150
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +71 to 73
<label className="input-label">Tags (comma-separated)</label>
<input
id="csv-tags"
type="text"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@dieterolson

Copy link
Copy Markdown
Collaborator Author

Consolidated into master PR #3951.

auto-merge was automatically disabled July 29, 2026 01:49

Pull request was closed

@dieterolson dieterolson reopened this Jul 29, 2026
@dieterolson
dieterolson enabled auto-merge (squash) July 29, 2026 04:30
dieterolson and others added 8 commits August 1, 2026 19:08
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>
@dieterolson

Copy link
Copy Markdown
Collaborator Author

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

firmware/P1AMHardware.cpp clamps a sub-4 mA loop to 0 %:

percent = (mA - 4.0f) * (100.0f / 16.0f);
...
if (percent < 0.0f) { percent = 0.0f; }

Combined with the affine defaults from the last commit (range_min_c=-150.0 for analog K, range_min_c=65.0 for analog R), a broken loop or an unpowered FC-T1 reads −150 °C or +65 °C — maximally cold, i.e. call for full heat. The in-code comment says the deglitch filter, the cross-check and HH-on-either keep this fail-safe. Checked each against the actual code:

  • Deglitch filter is mis-configured for these channels. _filtered_channel builds it with no arguments, so it keeps the defaults _ZERO_FLOOR_C = 5.0 / _FULL_SCALE_C = 1400.0. Analog R's rail is 65 °C, above the 5 °C floor, so burnout_low can never fire on it; the only remaining guard is impossible_drop = drop >= 250, so a loop break below roughly 315 °C true temperature is accepted as a genuine reading. 1400 is also neither analog K's top (1372) nor analog R's (1768), so the high-side test is wrong on both.
  • Bootstrap accepts the rail. if self._last_good_c is None: return self._accept(raw) — a backend restart with the conditioners unpowered accepts −150 °C as good, with no jump to reject against.
  • The cross-check is no longer diverse. cross_check_channel returns the other analog channel, so both safety inputs share one 24 V supply, one conditioner family, one analog card and the same percent < 0 → 0 clamp. Lose that supply and analog K reads −150 (below the 100 °C "cold" threshold) while analog R reads 65 (below the 200 °C "hot" threshold), so TC_DISAGREE cannot fire.
  • HH cannot fire — both channels sit at their floors, nowhere near hh_limit_c.

Net: unpowered or broken conditioners with an analog source selected leaves the heater full-on with measured_temp_c frozen at −150/65 °C, no trip and no bound.

The tests encode this as intended behaviour, which is why it needs rework rather than a follow-up:

  • test_controls_on_analog_k_tag feeds exactly the broken-loop signature ({"TAG_14": 0.0, "TAG_15": 0.0}, commented as "−150 C, well below the band") and asserts status.relay_on is True.
  • test_thm_other_sensor_not_used_on_analog_path asserts "HH_TEMP" not in status.trips and relay_on is True with the card's type-R channel at 1400 °C.

Second blocker, independent of the above

SignalBroker::kNumInputs goes 6 → 8, which widens StorageManager::ConfigStruct by 8 bytes, but kMagic is not bumped. The constant's own comment states the rule:

// 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: input_routing[6..7] picks up the old output_routing values (10, 11 — the AO command tags), so AI2/AI3 would be published onto the power-supply command, and every PID plus all 32 four-limit interlocks shift. A sketch upload usually erases the FlashStorage region, which is the only reason this is not certain to bite.

Also needs resolving before merge

  • The counts→mA premise is unverified. float mA = counts * (20.0f / 8191.0f); assumes 0 counts = 0 mA, while firmware/README.md (edited by this PR) still describes the module as P1-4ADL2DAL-1 | 4 analog inputs + 2 analog outputs (all 4-20 mA). If the AI counts actually span 4–20 mA, (mA-4)*100/16 under-reads mid-scale by 12.5 % of span ≈ 190 °C (K) / 213 °C (R) — and under-reading temperature means over-heating, with HH tripping that much late. The bench note ("at their 4 mA live-zero, Analog K reads −150 C and Analog R reads 65 C") cannot distinguish the two mappings; both give 0 % at 4 mA after the clamp. A two-point check at a known mid-scale current is needed, and calibration/CALIBRATION.md was not updated.
  • The host side of the widened I/O contract was not updated. hardware.py's comment claims slots 0-3 TC, 4-7 AI0-3, but defaults.py still has input_routing=[hardware.tag_name(i) for i in range(6)] and modbus_client.py still reads count=6, so /api/routing and the Signal Routing tab can neither see nor set AI2/AI3 → TAG_14/15. The only way to get that routing is a fresh flash with erased NVRAM.
  • tags.get(channel.tag, 0.0) silently yields 0 % — and therefore −150/65/0 °C "cold" — for a tag absent from a non-empty scan dict, forever, never a fault.
  • Stop is still gated by disabled={busy || startStop.disabled} in TemperatureControl.tsx. busy is set by any in-flight request (including a source switch and its loadConfig() round-trip) and startStopView(undefined).disabled === true, so with the status stream down the operator cannot press Stop. The window.confirm removal in this PR is correct and verified; this residual gating is not.
  • hh_limit_c / setpoint_max_c are clamped with min(...) on every source switch and persisted — a one-way ratchet, so selecting a 900 °C analog channel once permanently caps the card path until someone edits config.
  • Unrelated churn is bundled in: TabBar.tsx / lib/tabs.ts revert prior loop optimizations back to filter + O(n²) includes, and CsvExporter.tsx converts a <form> to a <div>, removing the htmlFor/id label associations.

What is genuinely good here and worth keeping

The TC-card path is a real improvement and the review found no defect in it: active_tc_path defaults to TC_CARD, the burnout coil is re-asserted high-side every scan, switching sources is refused while E-stopped, set_permissive(False) de-energizes the coil inside the request, the TC-fault hold is bounded at 15 s and then faults, and scale_percent is arithmetically correct and DRY. test_temperature_stop.py is a good regression net for the Stop claim.

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. < 3.5 mA → NaN), per-channel deglitch configuration, a kMagic bump, the routing-width update, and a two-point calibration check.

⚠️ Auto-merge (squash) is currently armed on this PR. If its required checks go green it will merge itself with the above unresolved. It should be disabled until the analog path is reworked.

@dieterolson
dieterolson disabled auto-merge August 14, 2026 07:25
@dieterolson

Copy link
Copy Markdown
Collaborator Author

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
(or +65 °C depending on the break mode), which a heater controller reads as "far below setpoint" and
answers with full heat. That inverts the safety direction of the whole change: the failure mode of a
temperature input on a heater must be fail-high or fault, never fail-low.

All three mitigations claimed in the PR were checked and none is functional. Most tellingly, the PR's
own tests assert relay_on is True on the broken-loop signature
— the tests encode the inversion as
expected behaviour, so a green suite here is evidence against the change rather than for it.

Separately, it widens ConfigStruct without bumping kMagic, so existing controllers would read the
new layout with an old magic and mis-parse persisted configuration.

Why closed rather than left open: auto-merge (SQUASH) was armed on this PR, and in this repo
disarming does not hold — automation re-arms it. Left open, this would have merged itself. Closing is
the only durable state. The branch is untouched and this PR can be reopened once the analog path
fails high or faults.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant