diff --git a/SPEC.md b/SPEC.md index 89737f286c..71732f40e4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -85,6 +85,14 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific thermocouple reads and sometimes a blocking flash write, so assuming 100 ms understated Ki and overstated Kd whenever it overran (issue #4009). +### 2026-08-13 P1AM platform consolidation (historian + SCADA foundation) + +- `HistorianWriter.write` accepts and forwards the `signal_frame` quality + metadata that `poll_runtime.ScanLogger` supplies, so the qualified-signal + contract (#4091) and the pluggable historian forwarding path (#4065) compose + instead of one overwriting the other. The remote sink contract stays + `{tag: value}` + timestamp, so no sink implementation depends on the quality + model. ### 2026-08-05 Golf Club assembly type-checking compatibility @@ -94,6 +102,72 @@ Comprehensive monorepo housing 45+ utility tools for data processing, scientific Serialization facade methods keep typed local return values so narrow mypy runs agree with full-repository type information. +### 2026-08-03 Professional SCADA trustworthy foundation + +- `src/p1am_control_system` supports named synthetic principals, short-lived + digest-only sessions, server-side Viewer/Operator/Engineer/Admin roles, and + append-only audit records for every attempted API mutation. Audit payloads + are bounded and redact credential fields. +- A canonical qualified-signal contract carries value, source/server time, + quality, diagnostic reason, sequence, and source through polling, REST and + WebSocket APIs, historian storage, alarm eligibility, and the HMI. +- Supervisory alarm management provides deterministic priority, lifecycle, + acknowledgement, timed shelving, designed suppression, first-out, + deadband/delay, help, and performance metrics. It is not an independent + protection layer. +- Protected configuration changes follow immutable draft, validation, diff, + review, approval, activation, identification, and rollback states. Direct + activation is rejected and failed deployment does not publish a revision. +- Recovery archives are checksummed, exclude runtime databases and energized + state, and restore only as configuration drafts. System health reports build, + configuration, database, clock, storage, service, driver, and backup status + independently. +- Declarative acceptance scenarios operate only on an isolated synthetic + adapter and produce self-contained evidence packages with hashes, + expected/observed states, timing results, limitations, and sign-off fields. +- These features preserve existing control behavior and do not authorize live + deployment or inclusion of plant tags, addresses, values, recipes, + sequences, credentials, network details, or production data. + +### 2026-07-31 P1AM Plant Historian Forwarding (TimescaleDB) + +- `src/p1am_control_system/backend/historian_sink.py` introduces a + `HistorianSink` protocol and a `HistorianWriter` that composes the capture + throttle, the local SQLite write, and best-effort remote forwarding. The local + write remains on the caller's session so historian rows and alarm events still + commit together in `_poll_once`; sinks are a forwarding interface only and can + never affect local durability. +- `src/p1am_control_system/backend/historian_shipper.py` adds + `StoreAndForwardSink`: a bounded in-memory queue drained by a daemon worker + that owns all network I/O. The scan loop only ever performs a non-blocking + `put_nowait`, so an unreachable plant historian cannot add latency to the + 10 Hz control loop. Overflow drops oldest and is counted. Delivery is + at-most-once by design; SQLite remains the authoritative local store. +- `src/p1am_control_system/backend/timescale_writer.py` implements the remote + half against TimescaleDB with a lazily imported `psycopg`, COPY-based batch + insert, tag-name to surrogate-id resolution, and DSN password redaction. +- `src/p1am_control_system/backend/timescale/*.sql` define the historian schema: + a `tag_sample` hypertable, `compress_segmentby = tag_id` compression, 1-minute + and hierarchical 1-hour continuous aggregates carrying min/max/sum/count, + retention policies that downsample rather than delete, an `event_log` + hypertable for alarm analytics, and least-privilege `grafana_ro` / + `historian_rw` roles. +- `src/p1am_control_system/backend/settings.py` adds the `P1AM_TIMESCALE_*` + surface. Forwarding is **off by default**; enabling it without a DSN is + rejected at startup rather than silently forwarding nowhere. +- Typing convention for this package: the backend uses flat intra-package + imports, which mypy resolves only when invoked from the backend directory. The + pre-push hook and CI invoke it from the repo root, where those imports become + `Any`. New backend code therefore annotates locals at the return boundary + rather than relying on cross-module inference, and expresses + deliberately-invalid test arguments through an `Any`-typed local rather than a + `# type: ignore` comment (which `warn_unused_ignores` flags as redundant under + the root-relative resolution). +- `GET /api/historian/shipper` reports queue depth, lag, and drop counters so a + gap in a plant trend can be identified as a forwarding gap rather than + misread as a real process measurement. Engineering diagnostic only — + deliberately excluded from the operator alarm surface. + ### 2026-07-26 P1AM Control System Trend Crosshair Optimization - `src/p1am_control_system/frontend/src/components/TrendPlotOverlays.tsx` and `PlotCrosshair.tsx` reduce diff --git a/config/module_size_budget_baseline.json b/config/module_size_budget_baseline.json index 3eaa2dedf2..d0fa0a6228 100644 --- a/config/module_size_budget_baseline.json +++ b/config/module_size_budget_baseline.json @@ -4,7 +4,7 @@ "src/data_processing/data_processor/python/data_processor/ui/pyqt6/main_window.py": 2734, "src/electrode_advisor/python/electrode_advisor/ui/pyqt6/main_window.py": 4386, "src/electrode_advisor/tests/test_electrode_advisor_contracts.py": 1562, - "src/p1am_control_system/backend/main.py": 1440, + "src/p1am_control_system/backend/main.py": 1708, "src/rotation_converter/modern_robotics.py": 2130, "src/rotation_converter/ui/pyqt6/main_window.py": 1308, "src/shared/python/ai/gui/assistant_panel.py": 1334, diff --git a/dcs_scada.db b/dcs_scada.db new file mode 100644 index 0000000000..0a06b00940 Binary files /dev/null and b/dcs_scada.db differ diff --git a/docs/adr/ADR-007-plant-historian-timescaledb.md b/docs/adr/ADR-007-plant-historian-timescaledb.md new file mode 100644 index 0000000000..19d04fde1b --- /dev/null +++ b/docs/adr/ADR-007-plant-historian-timescaledb.md @@ -0,0 +1,196 @@ +# ADR-007: TimescaleDB + Grafana as the P1AM plant historian + +- Status: Accepted +- Date: 2026-07-31 +- Decision Makers: Dieter Olson +- Related Issues/PRs: [#4046](https://github.com/D-sorganization/Tools/issues/4046) (epic), #4047–#4052, #4054–#4056 + +## Context + +The P1AM control system persists process data to a single SQLite file +(`dcs_scada.db`). That file is well-tuned for a bench rig — WAL journaling, +`synchronous=NORMAL`, bulk insert per scan, a composite `(tag_name, timestamp)` +index, and a byte-capped auto-purge. It does not extend to a plant. + +Measured before this work: + +- Poll loop runs at 10 Hz (`P1AM_POLL_INTERVAL_S=0.1`). +- Historian writes are throttled to one per `P1AM_CAPTURE_INTERVAL_S` + (default `5.0`), so at 32 tags the steady-state write rate is ~6.4 rows/s. + With the throttle disabled it is ~320 rows/s (~27.6M rows/day). +- Retention is a byte-cap sweep that **deletes** oldest samples. There is no + downsampling, so long-horizon history is destroyed rather than aggregated. + +Gaps that matter at plant scale: + +1. **No downsampling.** Losing six-month trends to a byte cap is the wrong + trade; process engineering needs multi-year 1-minute rollups. +2. **No compression.** Float series compress 10–20x; we store them raw. +3. **Tag cardinality.** A real chemical plant is 5k–50k tags. At 1 Hz that is + ~10k rows/s, which SQLite on a Pi will not sustain beside a 10 Hz control + loop. +4. **Bespoke analytics.** `data_explorer_{router,service,expression,stats, +signals,models,enums}.py` re-implements query/transform/statistics that an + off-the-shelf tool provides, and we own that maintenance permanently. +5. **Single point of loss.** The historian shares storage with the controller. + +Hard constraint: **the control path may not be affected.** The 10 Hz scan loop +drives alarm evaluation, the HMI broadcast, and the E-stop re-engage path. +Anything that can add latency there is a safety regression, not a performance +one. + +## Decision Flow + +```mermaid +flowchart TD + A[SQLite historian will not scale] --> B{What shape is the data?} + B -->|Metrics only| C[VictoriaMetrics / Prometheus] + B -->|Process data with asset context| D{Need relational joins?} + D -->|Yes: area/unit/equipment| E[TimescaleDB] + D -->|No| F[InfluxDB / QuestDB] + E --> G{Control path impact?} + G -->|Must be zero| H[Store-and-forward, bounded queue, worker thread] + H --> I[SQLite stays source of truth] + I --> J[Decision Accepted] +``` + +## Decision + +Add a **Level 3/4 information layer** above the control system: + +- **TimescaleDB** as the plant historian. +- **Grafana** as the read-only visualisation and engineering-alerting surface. +- **Store-and-forward** from the control node: SQLite remains the authoritative + local record; forwarding is additive, best-effort, and at-most-once. +- Both run on a **separate host** from the control Pi. + +Why TimescaleDB specifically: + +1. **It is Postgres.** The existing SQLAlchemy/SQLModel layer ports with modest + effort rather than a rewrite. +2. **It is relational.** `PlantArea` → `PlantUnit` → `PlantEquipment` → + `TagDefinition` live in the same database and can be `JOIN`ed onto samples. + This is the decisive factor: for process data the analysis question is + "which reactor, which campaign, which charge", and a pure metrics store + cannot answer it without duplicating the asset model into labels. +3. **Compression and continuous aggregates** give the standard historian + pattern — raw for 90 days, 1-minute rollups for 2 years, 1-hour forever — + declaratively rather than as cron jobs. + +## Alternatives Considered + +1. **Stay on SQLite.** Zero migration cost, and adequate today at 32 tags and a + 5 s capture interval. Rejected because it forecloses plant scale and because + its retention destroys history rather than downsampling it. + +2. **InfluxDB.** Purpose-built for time series. Rejected: non-relational, so the + asset hierarchy has to be flattened into tags; Flux is deprecated, leaving + the query-language story unsettled; v3 Core's free tier constrains retention. + +3. **VictoriaMetrics.** Genuinely Apache-2, excellent compression and + high-cardinality handling. Rejected as primary: it is Prometheus-shaped, with + no relational joins and no natural home for quality codes or batch context. + **This is the fallback if the Timescale licence becomes unacceptable.** + +4. **QuestDB.** Apache-2, SQL, very fast ingest, real Grafana support. A + legitimate contender; rejected on ecosystem depth and the weaker relational + story relative to Postgres. + +5. **Prometheus.** Rejected outright as a historian. Pull-based, infra-metrics + oriented, ~2 weeks typical retention. Appropriate for monitoring the Pi's CPU + and disk; wrong for a process record. + +6. **Ignition (Inductive Automation).** What the industry actually uses, and + what a plant integrator would recommend: SCADA + historian + alarming + MES + in one, with a genuine ISA-18.2 alarm model and unlimited-tag licensing. + Rejected for now because the hard parts specific to this system — safety + state machine, MPC, PID tuning, Alicat and power-supply integration — are + already built here and would not transfer. **Revisit if this becomes a + commercial plant**; the licence cost is likely smaller than the cost of + maintaining a bespoke SCADA stack. + +7. **Superset / Metabase.** BI tools. Wrong shape for operational time series. + +## Licensing (deliberate, and a real constraint) + +- **Grafana is AGPLv3.** Internal plant use is fine. Shipping Grafana as part of + a customer deliverable raises a network-copyleft question. This repo feeds + customer-facing work, so the boundary matters: we deploy Grafana, we do not + redistribute it. +- **TimescaleDB is split-licensed**: Apache-2 core, Timescale License (TSL) for + compression and continuous aggregates — precisely the two features this + design depends on. Free to self-host, but **source-available, not OSI-open**, + with a restriction on offering it as a competing managed service. Terms have + shifted more than once; verify current text before any commercial commitment. +- If strict OSI-open becomes a hard requirement, migrate to VictoriaMetrics or + QuestDB and accept the loss of relational asset joins. + +## Consequences + +**Positive** + +- Multi-year history at usable resolution instead of a byte-capped window. +- 10–20x storage reduction on aged data. +- Off-box durability for the process record. +- Alarm-performance analytics (EEMUA 191 / ISA-18.2) become possible; these are + aggregate and retrospective, which a live HMI cannot do. +- A path to retiring bespoke `data_explorer_*` maintenance, if it proves out. + +**Negative** + +- A second host to operate, back up, and patch. +- A licence question that must be re-checked rather than assumed. +- Two sources of truth for reads, with the attendant risk that someone treats a + Grafana panel as authoritative. Mitigated by documentation and by keeping + Grafana on read-only credentials. +- At-most-once forwarding means the remote may have gaps the local store does + not. Mitigated by the ingest-health dashboard so gaps are visible as gaps. + +## Non-negotiables encoded in the implementation + +- Grafana is **never** in the control path; read-only DB role, no write-back. +- Operator alarms stay in `alarm_processing.py`. Grafana alerting has no + ISA-18.2 shelving/priority/ack model and is for engineering notification only. +- The shipper **cannot** block the poll loop: bounded queue, `put_nowait`, + worker thread owning all socket I/O, every remote exception swallowed. +- Nothing runs on the control Pi. +- Forwarding defaults to **off**; enabling it without a DSN fails at startup. + +## Component Diagram + +```mermaid +graph LR + subgraph Control["Control Pi (Level 1-2)"] + FW[P1AM firmware
interlocks + PID] + BE[FastAPI poll loop @10Hz] + HMI[React HMI] + SQL[(SQLite
source of truth)] + end + subgraph Hist["Historian host (Level 3-4)"] + TS[(TimescaleDB
hypertable + CAGGs)] + GF[Grafana
read-only] + end + FW -->|Modbus TCP| BE + BE --> HMI + BE --> SQL + BE -.->|bounded queue
best-effort, one-way| TS + TS --> GF +``` + +## Validation & Monitoring + +- `GET /api/historian/shipper` — queue depth, lag, drop and ship counters. +- _Historian Health (ingest)_ dashboard — measures arrival at the destination, + so it catches shipper outages, network partitions, and a stopped control node + alike. +- Compression ratio and continuous-aggregate job status are both surfaced; a + stalled aggregate combined with an active retention policy is the one failure + mode that destroys history, and it is monitored explicitly. + +## Revisit If + +- The plant becomes commercial and an integrator-supported stack is warranted + (→ Ignition). +- Timescale licence terms change unacceptably (→ VictoriaMetrics / QuestDB). +- More than one controller or a second vendor appears (→ add MQTT Sparkplug B + and a broker; the schema does not foreclose this). diff --git a/docs/adr/README.md b/docs/adr/README.md index 0166d13afa..84177c5fe9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ This directory stores architecture decisions for cross-tool boundaries and share | [ADR-004](ADR-004-ruff-formatter.md) | Accepted | Why ruff format was chosen over Black as the canonical Python formatter. | | [ADR-005](ADR-005-plugin-discovery-vs-registry.md) | Accepted | Dual-mode plugin registration: per-tool manifests merged with centralized tools.json. | | [ADR-006](ADR-006-type-safety-mypy-strict.md) | Accepted | Type safety enforcement strategy using mypy delta CI and py.typed marker. | +| [ADR-007](ADR-007-plant-historian-timescaledb.md) | Accepted | TimescaleDB + Grafana as the P1AM plant historian, above an untouched control path. | diff --git a/docs/development/professional-scada-epic.md b/docs/development/professional-scada-epic.md index 87a60658b1..98c04d3857 100644 --- a/docs/development/professional-scada-epic.md +++ b/docs/development/professional-scada-epic.md @@ -1,10 +1,13 @@ # Professional SCADA Product Epic -**Status:** approved for implementation against synthetic data and simulated -equipment only +**Status:** implemented on the consolidated development branch; local release +gates passed; remote protected-branch gates pending **Scope:** `src/p1am_control_system` +**Delivery:** all phases are consolidated on GitHub PR #4091. Earlier stacked +phase PRs are superseded and must not be merged independently. + **Safety boundary:** this epic does not authorize connection to or modification of a live plant or independent protection system @@ -56,48 +59,118 @@ must never be committed or uploaded. ### Phase A — Trustworthy foundation -- [ ] F01 named-user identity, role-based access, and append-only audit trail -- [ ] F02 end-to-end signal quality and communications health -- [ ] F03 professional alarm lifecycle and performance management -- [ ] F04 versioned configuration, approval, deployment, and rollback -- [ ] F05 backup, restore, deployment identity, and system-health center -- [ ] F12 FAT/HIL scenario runner and acceptance-evidence packages +- [x] F01 named-user identity, role-based access, and append-only audit trail +- [x] F02 end-to-end signal quality and communications health +- [x] F03 professional alarm lifecycle and performance management +- [x] F04 versioned configuration, approval, deployment, and rollback +- [x] F05 backup, restore, deployment identity, and system-health center +- [x] F12 FAT/HIL scenario runner and acceptance-evidence packages Exit criterion: the synthetic system can be operated, changed, faulted, audited, backed up, restored, and regression-tested without ambiguity about identity, data validity, active configuration, or evidence. +#### Phase A verification — 2026-08-03 + +| Feature | Direct evidence | +| --- | --- | +| F01 | Named principals, short-lived digest-only sessions, server-side role gates, append-only SQLite audit guards, automatic success/failure mutation capture, redaction, and paginated audit query tests. | +| F02 | Canonical qualified signal samples propagate value, source/server timestamps, quality, diagnostic, sequence, and source through poll frames, WebSocket/API schemas, historian migration/query, alarm eligibility, and HMI communications status. | +| F03 | Deterministic lifecycle domain and REST/HMI workflows cover priority, acknowledgment, timed shelving/unshelving, designed suppression, first-out, deadband/delay, help, and performance metrics. The panel is explicitly supervisory and not independent protection. | +| F04 | Immutable SQLite revisions and protected draft, validation, diff, review, approval, activation, supersession, and rollback workflows are role-gated and audited. The former direct route returns `409` without touching an adapter. Failed deployment never publishes runtime configuration. | +| F05 | Recovery archives verify package and entry SHA-256 values, exclude energized state and runtime/database data, and restore only as a draft. Identity and health report software/configuration, database, clock, storage, service, driver, primary transport, simulator, and recovery-verification status independently. | +| F12 | Machine-marked synthetic scenarios can access only the isolated in-memory adapter. Evidence archives contain scenario/software/configuration hashes, expected and observed states, synthetic alarm and audit records, timing windows/results, limitations, overall result, and blank sign-off fields. | + +Phase A release-gate evidence: Ruff, formatting, and strict mypy checks for all +new foundation modules pass; the complete backend suite passes with 971 tests +and 6 CI-only dependency checks skipped; the +complete frontend suite, TypeScript build, and production bundle pass; ESLint +has zero errors and two unchanged pre-existing hook warnings. No database, +runtime recovery archive, credential, or private control artifact is committed. +Unverified clock synchronization is intentionally reported as degraded rather +than inferred from an available wall clock. + ### Phase B — Professional operator experience -- [ ] F06 generic process overview and reusable high-performance faceplates -- [ ] F07 interlock, permissive, first-out, and managed-bypass view -- [ ] F08 historian context, annotations, comparisons, and reporting -- [ ] F10 asset health, calibration, and maintenance workspace -- [ ] F13 shift log, run/campaign context, and handover reporting +- [x] F06 generic process overview and reusable high-performance faceplates +- [x] F07 interlock, permissive, first-out, and managed-bypass view +- [x] F08 historian context, annotations, comparisons, and reporting +- [x] F10 asset health, calibration, and maintenance workspace +- [x] F13 shift log, run/campaign context, and handover reporting Exit criterion: an operator can navigate the synthetic process from overview to cause, understand abnormal conditions, and hand off unresolved work with traceable context. +#### Phase B verification — 2026-08-03 + +| Feature | Direct evidence | +| --- | --- | +| F06 | The machine-marked synthetic feed, reaction, and separation areas use a reusable accessible faceplate contract with value, timestamp, quality, mode, alarm, interlock, asset-detail, and trend-drill-down context. | +| F07 | Protection definitions preserve control/interlock/independent-protection categories, deterministic group first-out and consequences, and managed bypasses with engineer role, reason, 24-hour maximum expiry, persistent banner flag, automatic expiry, audit-covered REST mutation, and a non-bypassable policy. | +| F08 | Immutable SQLite-backed saved investigations reproduce time-bounded queries, tag metadata, transformations, charts, annotations, exact events, context, and an explicit preserve-or-exclude bad-data policy. Deterministic ZIP exports carry entry and package SHA-256 values; interpolation is not an accepted policy. | +| F10 | Deterministic reports cover calibration due, drift, flatline, command/feedback mismatch, noise, runtime, starts, and device statistics. Every finding is explicitly a maintenance advisory with `authoritative_trip=false`. | +| F13 | SQLite-backed entries attribute author, shift, run, unresolved actions, exact event times, and investigation checksums; search is deterministic. Sign-off hashes the entry and installs database guards against update/delete, while handover acknowledgment is a separate attributable append. | + +Phase B release-gate evidence: Ruff, formatting, and strict mypy checks for all +new domain, persistence, and API modules pass; the complete backend suite passes +with 995 tests and 6 CI-only dependency checks skipped; all 394 frontend tests, +TypeScript, and the production bundle pass; ESLint has zero errors and two +unchanged pre-existing hook warnings. The operator workspace and every new +record are explicitly synthetic and not a representation of confidential plant +logic, identifiers, limits, or operating values. + ### Phase C — Reusable control product -- [ ] F09 generic sequence/state and procedure demonstration -- [ ] F11 driver/plugin framework and device diagnostics -- [ ] F14 notification and escalation policies -- [ ] F15 high availability, time synchronization, and disaster-recovery mode +- [x] F09 generic sequence/state and procedure demonstration +- [x] F11 driver/plugin framework and device diagnostics +- [x] F14 notification and escalation policies +- [x] F15 high availability, time synchronization, and disaster-recovery mode Exit criterion: a representative unit and connector can be added through documented contracts, commissioned with scenarios, and operated through defined infrastructure faults. +#### Phase C verification — 2026-08-03 + +| Feature | Direct evidence | +| --- | --- | +| F09 | A simulator-only state machine deterministically covers start, run, hold, resume, stop, completion, abort, recovery, and timeout. Transitional states have explicit deadlines; invalid transitions and viewer commands fail closed; every event carries actor, reason, sequence, before/after, and synthetic/non-live markings. | +| F11 | Versioned connector descriptors declare owned read/write tags. Poll and command boundaries isolate exceptions, degrade only owned tags, reject unknown/failed commands closed, identify the responsible connector, validate finite values/tag ownership, and redact diagnostic secret fields. | +| F14 | Deterministic policy tests prove initial delay, designed suppression, escalation, acknowledgment cancellation, rate limiting, secret redaction, and an audit record for every delivery or policy outcome. The representative channel has no external delivery side effect. | +| F15 | Availability contracts enforce one command-authority lease, strictly ordered sequences/timestamps, bounded offline buffering and one-time reconciliation, clock-skew reliability, explicit RTO/RPO, and rejection of energizing commands while the HMI is unavailable. The UI states that these contracts do not claim deployed redundant hardware. | + +Phase C release-gate evidence: Ruff, formatting, and strict mypy checks for all +new procedure, connector, notification, availability, composition, and API +modules pass; the complete backend suite passes with 1,010 tests and 6 CI-only +dependency checks skipped; all 394 frontend tests, TypeScript, and the production +bundle pass; ESLint has zero errors and two unchanged pre-existing hook warnings. + ### Phase D — Advanced differentiation -- [ ] F16 advisory optimization, digital-twin, and advanced-control workspace +- [x] F16 advisory optimization, digital-twin, and advanced-control workspace Exit criterion: model outputs are reproducible, versioned, uncertainty-aware, reviewable, and unable to write authoritative commands without a separately approved integration. +Phase D TDD evidence: the RED run failed collection because the advisory domain +and router did not exist. GREEN added five passing domain/API contract tests for +deterministic results, model and data provenance, bounded constraints, +confidence intervals, replay checksums, attributable dispositions, invalid +input rejection, and the absence of command/write routes. REFACTOR introduced +canonical hashing, immutable contracts, retained identical evaluations, strict +dependency checks, and shared schema validation while preserving the no-write +boundary. + +Phase D release-gate evidence: Ruff, formatting, and strict mypy checks pass for +the advisory domain and API; the complete backend suite passes with 1,015 tests +and 6 CI-only dependency checks skipped; all 394 frontend tests, TypeScript, and +the production bundle pass; ESLint has zero errors and two unchanged +pre-existing hook warnings. The UI and in-app help label the model and data as +synthetic, disclose that the representative linear projection is not validated +against a plant, and state that no authoritative write path exists. + ## Feature acceptance matrix | ID | Required evidence | @@ -132,6 +205,28 @@ approved integration. - Documentation, operator help, API schema, and specification match behavior. - Each child issue is closed only by a merged PR or an approved exempt label. +### Consolidated single-PR evidence — 2026-08-04 + +- Phase A through Phase D are present together on one development branch and + one PR, with the original pre-epic recovery ref and verified external backup + package retained. +- The complete backend suite passes with 1,016 tests and 6 CI-only dependency + checks skipped locally. +- All 394 frontend tests pass; ESLint reports zero errors and two unchanged + hook warnings; TypeScript and the production Vite build pass. +- All 41 changed production Python modules pass strict mypy. The complete + P1AM Python surface passes Ruff lint and Ruff formatting. +- The repository detect-secrets baseline contract passes all 23 tests. The two + keyword detections are explicit synthetic redaction fixtures with line-level + allowlist annotations; no runtime database, credential, real tag/address, + plant limit, recipe, sequence, or native controls artifact is included. +- Focused identity, configuration, qualified-signal, alarm, connector, + operator, reusable-product, and advisory route regressions pass after the + final consolidation refactor. +- Black is not used to rewrite the changed files because the repository's + authoritative Ruff formatter targets Python 3.14 and the local Black safety + check runs under Python 3.13; Ruff formatting is the enforced project gate. + ## Completion rule The epic is complete only when every feature row has direct evidence, every diff --git a/scripts/test_assertion_allowlist.txt b/scripts/test_assertion_allowlist.txt index 65fdb4f8bd..50cc459cbb 100644 --- a/scripts/test_assertion_allowlist.txt +++ b/scripts/test_assertion_allowlist.txt @@ -16,5 +16,9 @@ src/movement_optimizer/tests/**/__init__.py src/movement_optimizer/tests/**/conftest.py # P1AM power-supply test construction helpers shared by split runtime tests. src/p1am_control_system/backend/tests/_power_supply_helpers.py +# P1AM route inventory helper: derives paths/methods from app.openapi() so the +# authz-matrix tests are version-agnostic (fastapi 0.141's include_router no +# longer flattens into app.routes). Exports methods_by_path/route_paths only. +src/p1am_control_system/backend/tests/_route_inventory.py # pdf_renamer sub-app: conftest only puts the sub-app's own src root on sys.path. src/document_processing/pdf_renamer/tests/conftest.py diff --git a/src/p1am_control_system/USER_MANUAL.md b/src/p1am_control_system/USER_MANUAL.md index 70a4179ce9..3eced6f69f 100644 --- a/src/p1am_control_system/USER_MANUAL.md +++ b/src/p1am_control_system/USER_MANUAL.md @@ -13,11 +13,11 @@ screen of the operator interface. ## 1. What this system controls -| Subsystem | Actuator | Feedback | Purpose | -| --- | --- | --- | --- | +| Subsystem | Actuator | Feedback | Purpose | +| ------------------- | ------------------------------------------------ | ----------------------------- | ------------------------------------------- | | **Crucible heater** | 110 V AC resistive element via a 24 V DO → relay | Type-K + type-R thermocouples | Heat the crucible to a setpoint (0–1400 °C) | -| **Power supply** | Programmable supply via 0–5 V analog command | Current + voltage monitor | Deliver a commanded current/power | -| **Mass flow** | Alicat MFCs (serial) | Flow / pressure / temperature | Meter process gas | +| **Power supply** | Programmable supply via 0–5 V analog command | Current + voltage monitor | Deliver a commanded current/power | +| **Mass flow** | Alicat MFCs (serial) | Flow / pressure / temperature | Meter process gas | The **heater is the primary controlled process**: a resistive element wraps the crucible; the PLC switches it on and off through a relay, using thermocouple @@ -74,19 +74,19 @@ micro, acting as a Modbus-TCP **server** at `192.168.1.100:502`. Firmware FQBN: **Coils (discrete commands from the backend):** -| Coil | Function | -| --- | --- | -| 0 | Save-to-flash | -| 1 | E-stop reset | +| Coil | Function | +| ----- | ------------------------------------------------- | +| 0 | Save-to-flash | +| 1 | E-stop reset | | **2** | **Heater relay command** (temperature controller) | **Modules on the backplane and their tag mapping:** -| Slot | Module | Channels → tags | Notes | -| --- | --- | --- | --- | -| THM | **P1-04THM** | Ch1 (type K) → `TAG_0`, Ch2 (type R) → `TAG_1`, Ch3–4 (type K) → `TAG_2/3` | Celsius, **low-side burnout**, on-module linearization | -| DO | **P1-08TD2** | Heater relay = **coil 2** | 24 V discrete out → relay → 110 V heater | -| ANA | **P1-4ADL2DAL** | AI0/AI1 → `TAG_12/13`, AO0/AO1 ← `TAG_10/11` | Power-supply monitor + command | +| Slot | Module | Channels → tags | Notes | +| ---- | --------------- | -------------------------------------------------------------------------- | ------------------------------------------------------ | +| THM | **P1-04THM** | Ch1 (type K) → `TAG_0`, Ch2 (type R) → `TAG_1`, Ch3–4 (type K) → `TAG_2/3` | Celsius, **low-side burnout**, on-module linearization | +| DO | **P1-08TD2** | Heater relay = **coil 2** | 24 V discrete out → relay → 110 V heater | +| ANA | **P1-4ADL2DAL** | AI0/AI1 → `TAG_12/13`, AO0/AO1 ← `TAG_10/11` | Power-supply monitor + command | **Signal scaling.** Every analog channel is carried as **0–100 % of full scale**. The P1-04THM does per-type linearization on-module and the firmware reads degrees C @@ -132,11 +132,13 @@ read, displayed, and plotted, and the non-controlling one is used as an independ safety reference. ### Selecting and switching + Switching the controlling probe (K ↔ R) is **smooth** — it does not stop the heater. The live value of each probe is shown next to its selector so a dead or stuck sensor is obvious at a glance. ### Failure modes and what they look like + - **Reads 0 °C:** the P1-04THM's **low-side burnout** response to an **open input** (loose/broken connection, or a high-resistance/degraded element). - **Stuck near ambient while the vessel is hot:** the junction is not thermally @@ -147,6 +149,7 @@ is obvious at a glance. breakdown inside the sheath. This is a **wiring/probe** fault, not a control bug. ### High-temperature notes + At ~1300 °C type K is near the top of its practical range; elements can develop high-resistance or intermittent opens. For sustained high-temperature work, prefer an **ungrounded (isolated) junction**, adequate wire gauge, and a probe rated for the @@ -217,6 +220,7 @@ settings. ## 8. Operating procedures ### Start a heat run + 1. Confirm the HMI header shows **CONNECTED** and the E-stop is clear. 2. Open **Heater Controls**. Check both thermocouple readings are live and sane. 3. Select the controlling thermocouple (default **type K**). @@ -225,12 +229,14 @@ settings. fit window. ### Recover from a trip + 1. Read the banner / **Events & Alarms** to see which trip fired (HH, TC_FAULT, TC_DISAGREE). 2. Resolve the cause (let it cool below HH, fix the sensor, etc.). 3. **Acknowledge** the trip, then Start again. ### Redeploy after a code/config change + The services must restart to load new backend code or a new HMI build. A restart stops the heater (it returns **IDLE** with the setpoint recalled). Coordinate it for a moment the heater can pause, then: @@ -246,21 +252,22 @@ The frontend rebuilds on start; give it ~30 s to bind port 3002. ## 9. Troubleshooting -| Symptom | Likely cause | Action | -| --- | --- | --- | -| Reading drops to **0 °C** | Open input → module burnout | Check the probe/connections; the deglitch filter protects control meanwhile | -| Drops only at **high temp** | Connection/element opens with thermal expansion; insulation breakdown | Re-terminate hot-side joints; inspect/replace the element; use an isolated-junction probe | -| Probe stuck near **ambient** while hot | Junction not coupled / leads reversed | Re-seat/insert the probe; verify polarity and extension-wire type | -| Heater **won't start** | Not permissive, tripped, or E-stopped | Acknowledge trips, clear E-stop, press Start | -| HMI shows **OFFLINE** | Backend/PLC comms down | Check services (`systemctl`), the PLC network, and Modbus at `192.168.1.100:502` | -| **TC_DISAGREE** trip | Control probe reads cold while other reads hot | Don't control off a dead probe; fix the sensor | +| Symptom | Likely cause | Action | +| -------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Reading drops to **0 °C** | Open input → module burnout | Check the probe/connections; the deglitch filter protects control meanwhile | +| Drops only at **high temp** | Connection/element opens with thermal expansion; insulation breakdown | Re-terminate hot-side joints; inspect/replace the element; use an isolated-junction probe | +| Probe stuck near **ambient** while hot | Junction not coupled / leads reversed | Re-seat/insert the probe; verify polarity and extension-wire type | +| Heater **won't start** | Not permissive, tripped, or E-stopped | Acknowledge trips, clear E-stop, press Start | +| HMI shows **OFFLINE** | Backend/PLC comms down | Check services (`systemctl`), the PLC network, and Modbus at `192.168.1.100:502` | +| **TC_DISAGREE** trip | Control probe reads cold while other reads hot | Don't control off a dead probe; fix the sensor | ### Is a thermocouple problem the PLC, the sampling rate, or the setup? + The burnout-zeros are the **module's open-circuit detection** reporting an open input, so the answer is usually the **field side**, not the sampling rate: - **Sampling rate is not the cause of the zeros.** Firmware reads at 10 Hz, faster - than the P1-04THM's own conversion, so you *oversample* the module — this changes + than the P1-04THM's own conversion, so you _oversample_ the module — this changes how many zeros you observe, not whether they occur. - **With tight connections, suspect the probe's high-temperature electrical behavior:** rising loop resistance or insulation-resistance breakdown at high @@ -274,7 +281,7 @@ input, so the answer is usually the **field side**, not the sampling rate: ## 10. Deployment and maintenance - **Services:** `p1am-backend` (FastAPI/uvicorn) and `p1am-frontend` (`vite - preview`), both `Restart=always` under systemd. Install via +preview`), both `Restart=always` under systemd. Install via `deploy/install-services.sh`. - **Bench mode:** `P1AM_DEV_NO_AUTH=1` (admin endpoints unauthenticated), `PLC_DRIVER=modbus`. When the PLC is offline the backend runs a simulator so the @@ -284,7 +291,80 @@ input, so the answer is usually the **field side**, not the sampling rate: - **Tuning knobs (env):** `P1AM_POLL_INTERVAL_S` (default 0.1 s), the lightweight poll interval, and the capture/log-throttle interval. +## 11. The plant historian and Grafana (optional) + +The system can forward its process data to a separate **plant historian** +(TimescaleDB) with **Grafana** dashboards on top. This is off by default. When +it is on, nothing about how you operate the plant changes. + +### What Grafana is — and is not + +- **It is** a place to look at long-horizon history, compare campaigns, and + review alarm-system performance. It goes back years; the HMI trend does not. +- **It is not** an HMI. It cannot start, stop, or adjust anything. It has + read-only access to the database and no connection to the PLC at all. +- **If Grafana and the HMI disagree, the HMI is right.** The HMI reads the + controller directly. Grafana reads a copy that arrived over the network. + +Grafana is never the thing you act on during an upset. Use the HMI. + +### The one thing you must know + +A flat line in Grafana has two possible causes: + +1. The value genuinely did not change, or +2. **No data arrived.** + +These look identical. Before concluding anything from a flat or missing trend, +open the **Historian Health (ingest)** dashboard. If "ingest lag" is large, you +are looking at a gap in the recording, not a quiet process. + +This matters because forwarding is deliberately best-effort: if the network or +the historian is down, the control system keeps running and keeps recording +locally, and the copy sent to the historian is simply skipped. **The local +record on the Pi is always the complete one.** Nothing is lost from the control +system itself. + +### The dashboards + +| Dashboard | Answers | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Process Overview** | What did this tag do over hours, weeks, or years? Includes a min/max envelope, so brief excursions stay visible instead of being averaged away. | +| **Campaign Comparison** | Is this run behaving like a known-good run? Overlays a past campaign on the current one. | +| **Alarm Performance** | Is the alarm system helping or drowning the operator? Alarm rate, flood periods, worst-offender alarms, chattering, and standing alarms, against EEMUA 191 targets. | +| **Historian Health** | Is data actually arriving? Check this before trusting a gap. | + +The Alarm Performance dashboard is a review tool, not a live one. It does not +acknowledge, shelve, or silence anything — the alarm banner in the HMI remains +the only place alarms are handled. + +### Choosing the right resolution + +Process Overview has a **Resolution** selector because the historian keeps +different amounts of detail at different ages: + +| Looking back | Choose | +| -------------- | -------- | +| Up to 90 days | Raw | +| Up to 2 years | 1 minute | +| Anything older | 1 hour | + +If you pick a resolution that does not cover your time range the chart comes +back empty. Empty means "wrong selector", not "the plant was off". + +### Turning it off + +One environment variable on the Pi and a restart: + +```bash +P1AM_TIMESCALE_ENABLED=false +``` + +The control system carries on exactly as before with its local historian. Full +setup, troubleshooting, and rollback detail is in +`deploy/historian/README.md`. + --- -*This manual is the full version of the in-app Help. Open any tab and press the -Help button (📖) for that tab's quick reference.* +_This manual is the full version of the in-app Help. Open any tab and press the +Help button (📖) for that tab's quick reference._ diff --git a/src/p1am_control_system/backend/advisory_router.py b/src/p1am_control_system/backend/advisory_router.py new file mode 100644 index 0000000000..66a5288889 --- /dev/null +++ b/src/p1am_control_system/backend/advisory_router.py @@ -0,0 +1,46 @@ +"""REST review surface for synthetic non-authoritative advisories.""" + +from __future__ import annotations + +from collections.abc import Callable + +from advisory_workspace import ( + AdvisoryDisposition, + AdvisoryResult, + AdvisoryService, + DispositionRecord, + representative_advisory_request, +) +from fastapi import APIRouter, Depends, HTTPException +from identity import Principal + + +def create_advisory_router( + service: AdvisoryService, + operator_dependency: Callable[..., Principal], +) -> APIRouter: + """Create review-only routes; no authoritative command route is defined.""" + if not isinstance(service, AdvisoryService): + raise TypeError("service must be an AdvisoryService") + if not callable(operator_dependency): + raise TypeError("operator_dependency must be callable") + router = APIRouter(prefix="/api/operator/advisories", tags=["advisories"]) + + @router.get("/representative") + async def representative_advisory() -> AdvisoryResult: + return service.evaluate(representative_advisory_request()) + + @router.post("/{advisory_id}/dispositions") + async def record_disposition( + advisory_id: str, + body: AdvisoryDisposition, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> DispositionRecord: + try: + return service.record_disposition(advisory_id, body, principal) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + return router diff --git a/src/p1am_control_system/backend/advisory_workspace.py b/src/p1am_control_system/backend/advisory_workspace.py new file mode 100644 index 0000000000..b05b156874 --- /dev/null +++ b/src/p1am_control_system/backend/advisory_workspace.py @@ -0,0 +1,368 @@ +"""Reproducible synthetic advisories that cannot write control commands.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING, Literal + +from identity import Principal +from pydantic import BaseModel, ConfigDict, Field, model_validator + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +MODEL_DESCRIPTOR = { + "algorithm": "representative bounded linear projection", + "model_id": "SYNTHETIC.MODEL.ADVISORY", + "version": "1.0.0", +} +DEFAULT_MINIMUM = 40.0 +DEFAULT_MAXIMUM = 80.0 +CONFIDENCE_HALF_WIDTH = 2.5 +CONFIDENCE_LEVEL = 0.90 +THROUGHPUT_GAIN = 0.35 + + +def _canonical_sha256(payload: object) -> str: + """Return a stable SHA-256 for a JSON-compatible value.""" + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + default=_json_default, + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _json_default(value: object) -> object: + """Convert supported immutable contract values for canonical hashing.""" + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"unsupported canonical value: {type(value).__name__}") + + +def _required_text(value: str, name: str) -> str: + """Normalize required human or synthetic identifiers.""" + normalized = value.strip() + if not normalized: + raise ValueError(f"{name} must be non-empty") + return normalized + + +class AdvisoryRequest(BaseModel): + """Synthetic observations and target supplied to the advisory model.""" + + model_config = ConfigDict(frozen=True) + + dataset_id: str + observed_throughput: float + observed_energy: float + requested_throughput: float + + @model_validator(mode="after") + def validate_request(self) -> AdvisoryRequest: + """Enforce finite inputs and a synthetic dataset boundary.""" + object.__setattr__( + self, + "dataset_id", + _required_text(self.dataset_id, "dataset_id"), + ) + if not self.dataset_id.startswith("SYNTHETIC."): + raise ValueError("dataset_id must identify synthetic data") + values = ( + self.observed_throughput, + self.observed_energy, + self.requested_throughput, + ) + if not all(math.isfinite(value) for value in values): + raise ValueError("advisory inputs must be finite") + return self + + +class ConstraintEnvelope(BaseModel): + """Permitted range used to bound a recommendation.""" + + model_config = ConfigDict(frozen=True) + + minimum: float + maximum: float + unit: str + + @model_validator(mode="after") + def validate_range(self) -> ConstraintEnvelope: + """Require an ordered, finite constraint interval.""" + if not all(math.isfinite(value) for value in (self.minimum, self.maximum)): + raise ValueError("constraint values must be finite") + if self.minimum > self.maximum: + raise ValueError("minimum must not exceed maximum") + object.__setattr__(self, "unit", _required_text(self.unit, "unit")) + return self + + +class ConfidenceInterval(BaseModel): + """Uncertainty interval around one representative estimate.""" + + model_config = ConfigDict(frozen=True) + + level: float = Field(gt=0.0, lt=1.0) + lower: float + estimate: float + upper: float + + @model_validator(mode="after") + def validate_interval(self) -> ConfidenceInterval: + """Require a finite ordered interval containing the estimate.""" + values = (self.lower, self.estimate, self.upper) + if not all(math.isfinite(value) for value in values): + raise ValueError("confidence values must be finite") + if not self.lower <= self.estimate <= self.upper: + raise ValueError("confidence interval must contain estimate") + return self + + +class ModelProvenance(BaseModel): + """Identity of the versioned representative model artifact.""" + + model_config = ConfigDict(frozen=True) + + model_id: Literal["SYNTHETIC.MODEL.ADVISORY"] + version: str + algorithm: str + artifact_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class DataProvenance(BaseModel): + """Identity and digest of the exact synthetic model inputs.""" + + model_config = ConfigDict(frozen=True) + + dataset_id: str + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + feature_names: tuple[str, ...] + + +class ReplayEvidence(BaseModel): + """Digests needed to reproduce and compare an advisory result.""" + + model_config = ConfigDict(frozen=True) + + input_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + result_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + verified: Literal[True] = True + + +class AdvisoryResult(BaseModel): + """Review-only model result with explicit safety and provenance labels.""" + + model_config = ConfigDict(frozen=True) + + advisory_id: str + generated_at: datetime + model: ModelProvenance + data: DataProvenance + constraints: ConstraintEnvelope + confidence: ConfidenceInterval + recommended_setpoint: float + recommendation: str + limitation: str + replay: ReplayEvidence + authoritative_write_available: Literal[False] = False + data_classification: Literal["synthetic"] = "synthetic" + not_for_live_control: Literal[True] = True + + +class DispositionDecision(StrEnum): + """Review outcomes available to an operator.""" + + ACCEPTED_FOR_REVIEW = "accepted_for_review" + REJECTED = "rejected" + DEFERRED = "deferred" + + +class AdvisoryDisposition(BaseModel): + """Requested operator review disposition.""" + + model_config = ConfigDict(frozen=True) + + decision: DispositionDecision + reason: str + + @model_validator(mode="after") + def validate_reason(self) -> AdvisoryDisposition: + """Require an explicit review rationale.""" + object.__setattr__(self, "reason", _required_text(self.reason, "reason")) + return self + + +class DispositionRecord(BaseModel): + """Attributable append-only record that never applies a control value.""" + + model_config = ConfigDict(frozen=True) + + advisory_id: str + decision: DispositionDecision + reason: str + actor: str + recorded_at: datetime + applied_to_control: Literal[False] = False + + +class AdvisoryService: + """Evaluate and retain deterministic, non-authoritative advisories.""" + + def __init__(self, now: Callable[[], datetime]) -> None: + if not callable(now): + raise TypeError("now must be callable") + self._now = now + self._results: dict[str, AdvisoryResult] = {} + self._dispositions: list[DispositionRecord] = [] + + def evaluate(self, request: AdvisoryRequest) -> AdvisoryResult: + """Evaluate one request; postcondition: result is bounded and replayable.""" + if not isinstance(request, AdvisoryRequest): + raise TypeError("request must be an AdvisoryRequest") + input_payload = request.model_dump(mode="json") + input_sha256 = _canonical_sha256(input_payload) + model = self._model_provenance() + constraints = ConstraintEnvelope( + minimum=DEFAULT_MINIMUM, + maximum=DEFAULT_MAXIMUM, + unit="synthetic energy index", + ) + estimate = self._bounded_estimate(request, constraints) + confidence = ConfidenceInterval( + level=CONFIDENCE_LEVEL, + lower=max(constraints.minimum, estimate - CONFIDENCE_HALF_WIDTH), + estimate=estimate, + upper=min(constraints.maximum, estimate + CONFIDENCE_HALF_WIDTH), + ) + core = self._result_core(request, model, constraints, confidence) + advisory_id = str(core["advisory_id"]) + retained = self._results.get(advisory_id) + if retained is not None: + return retained + result_sha256 = _canonical_sha256(core) + # Annotated local: see the typing convention note in SPEC.md — CI runs + # mypy from the repo root, where flat intra-package imports become Any. + result: AdvisoryResult = AdvisoryResult.model_validate( + { + **core, + "replay": ReplayEvidence( + input_sha256=input_sha256, + result_sha256=result_sha256, + ), + } + ) + self._results[result.advisory_id] = result + return result + + def result(self, advisory_id: str) -> AdvisoryResult: + """Return one retained immutable advisory result.""" + normalized = _required_text(advisory_id, "advisory_id") + try: + return self._results[normalized] + except KeyError as exc: + raise KeyError("advisory result not found") from exc + + def record_disposition( + self, + advisory_id: str, + disposition: AdvisoryDisposition, + principal: Principal, + ) -> DispositionRecord: + """Append a review disposition without changing the advisory or controls.""" + result = self.result(advisory_id) + if not isinstance(disposition, AdvisoryDisposition): + raise TypeError("disposition must be an AdvisoryDisposition") + if not isinstance(principal, Principal): + raise TypeError("principal must be a Principal") + record = DispositionRecord( + advisory_id=result.advisory_id, + decision=disposition.decision, + reason=disposition.reason, + actor=principal.subject, + recorded_at=self._now(), + ) + self._dispositions.append(record) + return record + + def dispositions(self, advisory_id: str) -> tuple[DispositionRecord, ...]: + """Return disposition history for one known result.""" + result = self.result(advisory_id) + return tuple( + record + for record in self._dispositions + if record.advisory_id == result.advisory_id + ) + + @staticmethod + def _model_provenance() -> ModelProvenance: + return ModelProvenance( + model_id="SYNTHETIC.MODEL.ADVISORY", + version=MODEL_DESCRIPTOR["version"], + algorithm=MODEL_DESCRIPTOR["algorithm"], + artifact_sha256=_canonical_sha256(MODEL_DESCRIPTOR), + ) + + @staticmethod + def _bounded_estimate( + request: AdvisoryRequest, constraints: ConstraintEnvelope + ) -> float: + delta = request.requested_throughput - request.observed_throughput + unbounded = request.observed_energy + THROUGHPUT_GAIN * delta + return round(min(constraints.maximum, max(constraints.minimum, unbounded)), 3) + + def _result_core( + self, + request: AdvisoryRequest, + model: ModelProvenance, + constraints: ConstraintEnvelope, + confidence: ConfidenceInterval, + ) -> dict[str, object]: + input_payload = request.model_dump(mode="json") + identity_sha256 = _canonical_sha256( + {"input": input_payload, "model": model.model_dump(mode="json")} + ) + return { + "advisory_id": f"ADV-{identity_sha256[:16]}", + "generated_at": self._now(), + "model": model, + "data": DataProvenance( + dataset_id=request.dataset_id, + content_sha256=_canonical_sha256(input_payload), + feature_names=( + "observed_throughput", + "observed_energy", + "requested_throughput", + ), + ), + "constraints": constraints, + "confidence": confidence, + "recommended_setpoint": confidence.estimate, + "recommendation": "Review bounded synthetic setpoint in scenario", + "limitation": ( + "Representative linear projection only; not validated against a plant " + "and unable to issue authoritative commands." + ), + } + + +def representative_advisory_request() -> AdvisoryRequest: + """Return invented inputs for the product demonstration workspace.""" + return AdvisoryRequest( + dataset_id="SYNTHETIC.DATASET.REPRESENTATIVE-RUN", + observed_throughput=62.0, + observed_energy=47.0, + requested_throughput=68.0, + ) diff --git a/src/p1am_control_system/backend/alarm_lifecycle.py b/src/p1am_control_system/backend/alarm_lifecycle.py new file mode 100644 index 0000000000..54ee6a4bd5 --- /dev/null +++ b/src/p1am_control_system/backend/alarm_lifecycle.py @@ -0,0 +1,334 @@ +"""Deterministic alarm lifecycle, shelving, suppression, first-out, and metrics.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from identity import Principal, Role + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +class AlarmPriority(StrEnum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +class AlarmLifecycle(StrEnum): + INACTIVE = "inactive" + UNACKNOWLEDGED = "unacknowledged" + ACKNOWLEDGED = "acknowledged" + RETURNED_UNACKNOWLEDGED = "returned_unacknowledged" + SHELVED = "shelved" + SUPPRESSED = "suppressed" + + +def _required_text(value: object, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + normalized = value.strip() + if not normalized: + raise ValueError(f"{name} must be non-empty") + return normalized + + +def _aware(value: object, name: str) -> datetime: + if not isinstance(value, datetime): + raise TypeError(f"{name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{name} must be timezone-aware") + return value + + +@dataclass(frozen=True) +class AlarmDefinition: + tag: str + low_limit: float + high_limit: float + priority: AlarmPriority + deadband: float + on_delay: timedelta + off_delay: timedelta + help_text: str + suppression_rules: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + object.__setattr__(self, "tag", _required_text(self.tag, "tag")) + object.__setattr__( + self, "help_text", _required_text(self.help_text, "help_text") + ) + if not isinstance(self.priority, AlarmPriority): + raise TypeError("priority must be an AlarmPriority") + for name in ("low_limit", "high_limit", "deadband"): + numeric_value = float(getattr(self, name)) + if not math.isfinite(numeric_value): + raise ValueError(f"{name} must be finite") + object.__setattr__(self, name, numeric_value) + if self.low_limit >= self.high_limit: + raise ValueError("low_limit must be below high_limit") + if self.deadband < 0 or self.deadband * 2 >= self.high_limit - self.low_limit: + raise ValueError("deadband must be nonnegative and smaller than alarm span") + for name in ("on_delay", "off_delay"): + delay_value = getattr(self, name) + if not isinstance(delay_value, timedelta) or delay_value < timedelta(0): + raise ValueError(f"{name} must be a nonnegative timedelta") + rules = frozenset( + _required_text(rule, "suppression rule") for rule in self.suppression_rules + ) + object.__setattr__(self, "suppression_rules", rules) + + +@dataclass(frozen=True) +class AlarmSnapshot: + tag: str + priority: AlarmPriority + lifecycle: AlarmLifecycle + condition: str + acknowledged_by: str | None + shelved_by: str | None + shelf_reason: str | None + shelf_until: datetime | None + suppression_rule: str | None + first_out_sequence: int | None + active_since: datetime | None + help_text: str + + +@dataclass(frozen=True) +class AlarmPerformanceReport: + activations: int + acknowledged_activations: int + mean_acknowledgement_seconds: float | None + + +@dataclass +class _Runtime: + condition: str = "normal" + pending_condition: str | None = None + pending_since: datetime | None = None + lifecycle: AlarmLifecycle = AlarmLifecycle.INACTIVE + acknowledged_by: str | None = None + active_since: datetime | None = None + first_out_sequence: int | None = None + shelved_by: str | None = None + shelf_reason: str | None = None + shelf_until: datetime | None = None + suppression_rule: str | None = None + + +class AlarmManager: + """Own all lifecycle state for a validated set of alarm definitions.""" + + def __init__(self, definitions: list[AlarmDefinition]) -> None: + if not isinstance(definitions, list) or not definitions: + raise ValueError("definitions must be a non-empty list") + if not all(isinstance(item, AlarmDefinition) for item in definitions): + raise TypeError("definitions must contain AlarmDefinition values") + self._definitions = {item.tag: item for item in definitions} + if len(self._definitions) != len(definitions): + raise ValueError("alarm tags must be unique") + self._runtime = {tag: _Runtime() for tag in self._definitions} + self._first_out_counter = 0 + self._activations = 0 + self._acknowledgement_times: list[float] = [] + + def _definition(self, tag: str) -> AlarmDefinition: + try: + return self._definitions[tag] + except KeyError as exc: + raise KeyError(f"unknown alarm tag {tag!r}") from exc + + @staticmethod + def _candidate(definition: AlarmDefinition, runtime: _Runtime, value: float) -> str: + if ( + runtime.condition == "high" + and value >= definition.high_limit - definition.deadband + ): + return "high" + if ( + runtime.condition == "low" + and value <= definition.low_limit + definition.deadband + ): + return "low" + if value >= definition.high_limit: + return "high" + if value <= definition.low_limit: + return "low" + return "normal" + + def _commit_condition( + self, runtime: _Runtime, condition: str, now: datetime + ) -> None: + prior = runtime.condition + runtime.condition = condition + runtime.pending_condition = None + runtime.pending_since = None + if prior == "normal" and condition != "normal": + self._first_out_counter += 1 + self._activations += 1 + runtime.first_out_sequence = self._first_out_counter + runtime.active_since = now + runtime.acknowledged_by = None + runtime.lifecycle = AlarmLifecycle.UNACKNOWLEDGED + elif prior != "normal" and condition == "normal": + runtime.lifecycle = ( + AlarmLifecycle.INACTIVE + if runtime.acknowledged_by is not None + else AlarmLifecycle.RETURNED_UNACKNOWLEDGED + ) + elif condition != "normal" and runtime.lifecycle is AlarmLifecycle.INACTIVE: + runtime.lifecycle = AlarmLifecycle.UNACKNOWLEDGED + + @staticmethod + def _expire_shelf(runtime: _Runtime, now: datetime) -> None: + if runtime.shelf_until is not None and now >= runtime.shelf_until: + runtime.shelved_by = None + runtime.shelf_reason = None + runtime.shelf_until = None + + def _snapshot(self, tag: str, now: datetime) -> AlarmSnapshot: + definition = self._definition(tag) + runtime = self._runtime[tag] + self._expire_shelf(runtime, now) + lifecycle = runtime.lifecycle + if runtime.suppression_rule is not None: + lifecycle = AlarmLifecycle.SUPPRESSED + elif runtime.shelf_until is not None: + lifecycle = AlarmLifecycle.SHELVED + return AlarmSnapshot( + tag=tag, + priority=definition.priority, + lifecycle=lifecycle, + condition=runtime.condition, + acknowledged_by=runtime.acknowledged_by, + shelved_by=runtime.shelved_by, + shelf_reason=runtime.shelf_reason, + shelf_until=runtime.shelf_until, + suppression_rule=runtime.suppression_rule, + first_out_sequence=runtime.first_out_sequence, + active_since=runtime.active_since, + help_text=definition.help_text, + ) + + def evaluate(self, tag: str, value: float, now: datetime) -> AlarmSnapshot: + definition = self._definition(tag) + runtime = self._runtime[tag] + stamp = _aware(now, "now") + numeric = float(value) + if not math.isfinite(numeric): + raise ValueError("alarm value must be finite") + candidate = self._candidate(definition, runtime, numeric) + if candidate == runtime.condition: + runtime.pending_condition = None + runtime.pending_since = None + return self._snapshot(tag, stamp) + if runtime.pending_condition != candidate: + runtime.pending_condition = candidate + runtime.pending_since = stamp + assert runtime.pending_since is not None + delay = definition.off_delay if candidate == "normal" else definition.on_delay + if stamp - runtime.pending_since >= delay: + self._commit_condition(runtime, candidate, stamp) + return self._snapshot(tag, stamp) + + def acknowledge( + self, tag: str, principal: Principal, now: datetime + ) -> AlarmSnapshot: + if not isinstance(principal, Principal) or not principal.allows(Role.OPERATOR): + raise PermissionError("alarm acknowledgement requires operator role") + runtime = self._runtime[self._definition(tag).tag] + stamp = _aware(now, "now") + if runtime.lifecycle not in { + AlarmLifecycle.UNACKNOWLEDGED, + AlarmLifecycle.RETURNED_UNACKNOWLEDGED, + }: + raise ValueError("alarm is not awaiting acknowledgement") + runtime.acknowledged_by = principal.subject + if runtime.active_since is not None: + self._acknowledgement_times.append( + (stamp - runtime.active_since).total_seconds() + ) + runtime.lifecycle = ( + AlarmLifecycle.INACTIVE + if runtime.condition == "normal" + else AlarmLifecycle.ACKNOWLEDGED + ) + return self._snapshot(tag, stamp) + + def shelve( + self, + tag: str, + principal: Principal, + reason: str, + until: datetime, + now: datetime, + ) -> AlarmSnapshot: + if not isinstance(principal, Principal) or not principal.allows(Role.OPERATOR): + raise PermissionError("alarm shelving requires operator role") + stamp = _aware(now, "now") + expiry = _aware(until, "until") + if expiry <= stamp or expiry - stamp > timedelta(days=1): + raise ValueError("shelf expiry must be within the next day") + runtime = self._runtime[self._definition(tag).tag] + runtime.shelved_by = principal.subject + runtime.shelf_reason = _required_text(reason, "reason") + runtime.shelf_until = expiry + return self._snapshot(tag, stamp) + + def set_suppression( + self, + tag: str, + rule: str, + active: bool, + now: datetime, + ) -> AlarmSnapshot: + definition = self._definition(tag) + rule_id = _required_text(rule, "suppression rule") + if rule_id not in definition.suppression_rules: + raise ValueError("suppression rule is not designed for this alarm") + if not isinstance(active, bool): + raise TypeError("active must be a bool") + runtime = self._runtime[tag] + runtime.suppression_rule = rule_id if active else None + return self._snapshot(tag, _aware(now, "now")) + + def unshelve( + self, + tag: str, + principal: Principal, + now: datetime, + ) -> AlarmSnapshot: + if not isinstance(principal, Principal) or not principal.allows(Role.OPERATOR): + raise PermissionError("alarm unshelving requires operator role") + runtime = self._runtime[self._definition(tag).tag] + runtime.shelved_by = None + runtime.shelf_reason = None + runtime.shelf_until = None + return self._snapshot(tag, _aware(now, "now")) + + def snapshot(self, tag: str, now: datetime) -> AlarmSnapshot: + return self._snapshot(tag, _aware(now, "now")) + + def active_snapshots(self, now: datetime) -> list[AlarmSnapshot]: + stamp = _aware(now, "now") + snapshots = [self._snapshot(tag, stamp) for tag in self._definitions] + active = [ + item for item in snapshots if item.lifecycle is not AlarmLifecycle.INACTIVE + ] + return sorted(active, key=lambda item: item.first_out_sequence or math.inf) + + def performance_report(self) -> AlarmPerformanceReport: + count = len(self._acknowledgement_times) + mean = sum(self._acknowledgement_times) / count if count else None + return AlarmPerformanceReport(self._activations, count, mean) diff --git a/src/p1am_control_system/backend/alarm_router.py b/src/p1am_control_system/backend/alarm_router.py new file mode 100644 index 0000000000..593ed74bf1 --- /dev/null +++ b/src/p1am_control_system/backend/alarm_router.py @@ -0,0 +1,94 @@ +"""Supervisory professional alarm-management REST API.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import cast + +from alarm_lifecycle import AlarmPerformanceReport, AlarmSnapshot +from alarm_service import AlarmService +from fastapi import APIRouter, Depends, HTTPException +from identity import Principal +from pydantic import BaseModel, Field + + +class ShelfRequest(BaseModel): + reason: str = Field(min_length=1, max_length=500) + duration_seconds: int = Field(ge=1, le=86_400) + + +class SuppressionRequest(BaseModel): + rule: str = Field(min_length=1, max_length=200) + active: bool + + +def _domain_call(operation: Callable[[], AlarmSnapshot]) -> AlarmSnapshot: + try: + return operation() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except (PermissionError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +def create_alarm_router( + service: AlarmService, + operator_dependency: Callable[..., Principal], + engineer_dependency: Callable[..., Principal], +) -> APIRouter: + """Build a role-aware router over one alarm application service.""" + if not isinstance(service, AlarmService): + raise TypeError("service must be an AlarmService") + if not callable(operator_dependency) or not callable(engineer_dependency): + raise TypeError("alarm authorization dependencies must be callable") + router = APIRouter(prefix="/api/alarm-management", tags=["alarm-management"]) + + @router.get("/active") + async def active() -> list[AlarmSnapshot]: + return cast(list[AlarmSnapshot], service.active()) + + @router.post("/{tag}/acknowledge") + async def acknowledge( + tag: str, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> AlarmSnapshot: + return _domain_call(lambda: service.acknowledge(tag, principal)) + + @router.post("/{tag}/shelf") + async def shelve( + tag: str, + request: ShelfRequest, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> AlarmSnapshot: + return _domain_call( + lambda: service.shelve( + tag, + principal, + request.reason, + timedelta(seconds=request.duration_seconds), + ) + ) + + @router.delete("/{tag}/shelf") + async def unshelve( + tag: str, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> AlarmSnapshot: + return _domain_call(lambda: service.unshelve(tag, principal)) + + @router.post("/{tag}/suppression") + async def suppress( + tag: str, + request: SuppressionRequest, + _principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> AlarmSnapshot: + return _domain_call(lambda: service.suppress(tag, request.rule, request.active)) + + @router.get("/performance") + async def performance( + _principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> AlarmPerformanceReport: + return service.performance() + + return router diff --git a/src/p1am_control_system/backend/alarm_service.py b/src/p1am_control_system/backend/alarm_service.py new file mode 100644 index 0000000000..a1ac022acc --- /dev/null +++ b/src/p1am_control_system/backend/alarm_service.py @@ -0,0 +1,123 @@ +"""Thread-safe application boundary for the professional alarm manager.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from datetime import datetime, timedelta, timezone + +from alarm_lifecycle import ( + AlarmDefinition, + AlarmManager, + AlarmPerformanceReport, + AlarmPriority, + AlarmSnapshot, +) +from identity import Principal +from models import RoutingConfig + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def manager_from_routing(config: RoutingConfig) -> AlarmManager: + """Adapt generic four-tier routing limits into supervisory lifecycle alarms.""" + if not isinstance(config, RoutingConfig): + raise TypeError("config must be a RoutingConfig") + definitions: list[AlarmDefinition] = [] + for tag, limits in config.interlocks.items(): + ordered = ( + limits.lolo_limit < limits.low_limit < limits.high_limit < limits.hihi_limit + ) + if not ordered: + raise ValueError(f"alarm limits for {tag!r} must be strictly ordered") + span = limits.high_limit - limits.low_limit + definitions.append( + AlarmDefinition( + tag=tag, + low_limit=limits.low_limit, + high_limit=limits.high_limit, + priority=AlarmPriority.HIGH, + deadband=span * 0.01, + on_delay=timedelta(seconds=1), + off_delay=timedelta(seconds=1), + help_text=("Review signal quality and the generic process context."), + suppression_rules=frozenset({"synthetic.maintenance"}), + ) + ) + return AlarmManager(definitions) + + +class AlarmService: + """Serialize poll and API access through a narrow manager interface.""" + + def __init__( + self, + manager: AlarmManager, + clock: Callable[[], datetime] | None = None, + ) -> None: + if not isinstance(manager, AlarmManager): + raise TypeError("manager must be an AlarmManager") + self._manager = manager + self._clock = clock or (lambda: datetime.now(UTC)) + self._lock = threading.RLock() + + def reconfigure(self, manager: AlarmManager) -> None: + """Atomically replace definitions; protected activation is handled by F04.""" + if not isinstance(manager, AlarmManager): + raise TypeError("manager must be an AlarmManager") + with self._lock: + self._manager = manager + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + def observe(self, values: dict[str, float], now: datetime | None = None) -> None: + if not isinstance(values, dict): + raise TypeError("values must be a dict") + stamp = now or self._now() + with self._lock: + for tag, value in values.items(): + try: + self._manager.evaluate(tag, value, stamp) + except KeyError: + continue + + def active(self) -> list[AlarmSnapshot]: + with self._lock: + snapshots: list[AlarmSnapshot] = self._manager.active_snapshots(self._now()) + return snapshots + + def acknowledge(self, tag: str, principal: Principal) -> AlarmSnapshot: + with self._lock: + return self._manager.acknowledge(tag, principal, self._now()) + + def shelve( + self, + tag: str, + principal: Principal, + reason: str, + duration: timedelta, + ) -> AlarmSnapshot: + if not isinstance(duration, timedelta) or duration <= timedelta(0): + raise ValueError("duration must be a positive timedelta") + now = self._now() + with self._lock: + return self._manager.shelve(tag, principal, reason, now + duration, now) + + def unshelve(self, tag: str, principal: Principal) -> AlarmSnapshot: + with self._lock: + return self._manager.unshelve(tag, principal, self._now()) + + def suppress(self, tag: str, rule: str, active: bool) -> AlarmSnapshot: + with self._lock: + return self._manager.set_suppression(tag, rule, active, self._now()) + + def performance(self) -> AlarmPerformanceReport: + with self._lock: + return self._manager.performance_report() diff --git a/src/p1am_control_system/backend/asset_health.py b/src/p1am_control_system/backend/asset_health.py new file mode 100644 index 0000000000..4aa5508073 --- /dev/null +++ b/src/p1am_control_system/backend/asset_health.py @@ -0,0 +1,222 @@ +"""Deterministic synthetic asset statistics and maintenance advisories.""" + +from __future__ import annotations + +import math +import statistics +from collections.abc import Callable, Sequence +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +def _aware(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must include a UTC offset") + return value + + +class AdvisoryCode(StrEnum): + CALIBRATION_DUE = "calibration_due" + DRIFT = "drift" + FLATLINE = "flatline" + COMMAND_FEEDBACK_MISMATCH = "command_feedback_mismatch" + NOISY_SIGNAL = "noisy_signal" + + +class AssetHealthPolicy(BaseModel): + model_config = ConfigDict(frozen=True) + + drift_limit: float = Field(default=2.0, gt=0) + flatline_duration: timedelta = timedelta(minutes=5) + flatline_span: float = Field(default=0.01, ge=0) + mismatch_duration: timedelta = timedelta(seconds=30) + noise_standard_deviation: float = Field(default=5.0, gt=0) + + @model_validator(mode="after") + def _positive_durations(self) -> AssetHealthPolicy: + if self.flatline_duration <= timedelta(0): + raise ValueError("flatline_duration must be positive") + if self.mismatch_duration <= timedelta(0): + raise ValueError("mismatch_duration must be positive") + return self + + +class AssetObservation(BaseModel): + model_config = ConfigDict(frozen=True) + + observed_at: datetime + value: float + reference: float + command: bool + feedback: bool + running: bool + + @field_validator("observed_at") + @classmethod + def _timestamp_is_aware(cls, value: datetime) -> datetime: + return _aware(value) + + @field_validator("value", "reference") + @classmethod + def _finite_values(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("observation values must be finite") + return value + + +class AssetCounters(BaseModel): + model_config = ConfigDict(frozen=True) + + runtime_seconds: float = Field(ge=0) + start_count: int = Field(ge=0) + + +class DeviceStatistics(BaseModel): + model_config = ConfigDict(frozen=True) + + sample_count: int = Field(gt=0) + minimum: float + maximum: float + mean: float + standard_deviation: float = Field(ge=0) + + +class MaintenanceAdvisory(BaseModel): + model_config = ConfigDict(frozen=True) + + code: AdvisoryCode + asset_id: str + detected_at: datetime + detail: str + classification: Literal["maintenance_advisory"] = "maintenance_advisory" + authoritative_trip: Literal[False] = False + + +class AssetHealthReport(BaseModel): + model_config = ConfigDict(frozen=True) + + asset_id: str + generated_at: datetime + counters: AssetCounters + statistics: DeviceStatistics + advisories: tuple[MaintenanceAdvisory, ...] + data_classification: Literal["synthetic"] = "synthetic" + + +class AssetHealthService: + def __init__( + self, + policy: AssetHealthPolicy, + now: Callable[[], datetime], + ) -> None: + self._policy = policy + self._now = now + + @staticmethod + def _validate_observations( + observations: Sequence[AssetObservation], + ) -> tuple[AssetObservation, ...]: + normalized = tuple(observations) + if len(normalized) < 2: + raise ValueError("at least two observations are required") + if any( + current.observed_at <= previous.observed_at + for previous, current in zip(normalized, normalized[1:], strict=False) + ): + raise ValueError("observations must be strictly time ordered") + return normalized + + @staticmethod + def _counters(observations: tuple[AssetObservation, ...]) -> AssetCounters: + runtime = sum( + (current.observed_at - previous.observed_at).total_seconds() + for previous, current in zip(observations, observations[1:], strict=False) + if previous.running + ) + starts = int(observations[0].running) + sum( + int(current.running and not previous.running) + for previous, current in zip(observations, observations[1:], strict=False) + ) + return AssetCounters(runtime_seconds=runtime, start_count=starts) + + @staticmethod + def _mismatch_span(observations: tuple[AssetObservation, ...]) -> timedelta: + mismatched = [item for item in observations if item.command != item.feedback] + if len(mismatched) < 2: + return timedelta(0) + trailing: list[AssetObservation] = [] + for item in reversed(observations): + if item.command == item.feedback: + break + trailing.append(item) + if len(trailing) < 2: + return timedelta(0) + return trailing[0].observed_at - trailing[-1].observed_at + + def assess( + self, + asset_id: str, + observations: Sequence[AssetObservation], + *, + calibration_due_at: datetime, + ) -> AssetHealthReport: + if not asset_id.startswith("SYNTHETIC."): + raise ValueError("asset_id must begin with SYNTHETIC.") + normalized = self._validate_observations(observations) + generated_at = _aware(self._now()) + calibration_due_at = _aware(calibration_due_at) + values = [item.value for item in normalized] + stats = DeviceStatistics( + sample_count=len(values), + minimum=min(values), + maximum=max(values), + mean=statistics.fmean(values), + standard_deviation=statistics.pstdev(values), + ) + advisories: list[MaintenanceAdvisory] = [] + + def add(code: AdvisoryCode, detail: str) -> None: + advisories.append( + MaintenanceAdvisory( + code=code, + asset_id=asset_id, + detected_at=generated_at, + detail=detail, + ) + ) + + if generated_at >= calibration_due_at: + add(AdvisoryCode.CALIBRATION_DUE, "Calibration due date has passed") + latest = normalized[-1] + if abs(latest.value - latest.reference) > self._policy.drift_limit: + add(AdvisoryCode.DRIFT, "Value-to-reference deviation exceeds policy") + duration = normalized[-1].observed_at - normalized[0].observed_at + if ( + duration >= self._policy.flatline_duration + and stats.maximum - stats.minimum <= self._policy.flatline_span + ): + add(AdvisoryCode.FLATLINE, "Signal span remains below flatline policy") + if self._mismatch_span(normalized) >= self._policy.mismatch_duration: + add( + AdvisoryCode.COMMAND_FEEDBACK_MISMATCH, + "Command and feedback remain inconsistent", + ) + if stats.standard_deviation > self._policy.noise_standard_deviation: + add(AdvisoryCode.NOISY_SIGNAL, "Signal variability exceeds noise policy") + return AssetHealthReport( + asset_id=asset_id, + generated_at=generated_at, + counters=self._counters(normalized), + statistics=stats, + advisories=tuple(advisories), + ) diff --git a/src/p1am_control_system/backend/audit_log.py b/src/p1am_control_system/backend/audit_log.py new file mode 100644 index 0000000000..87efc2321b --- /dev/null +++ b/src/p1am_control_system/backend/audit_log.py @@ -0,0 +1,189 @@ +"""Append-only, secret-redacting audit domain and SQLite persistence.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from identity import Principal +from models import utc_now +from sqlalchemy import Engine, text +from sqlmodel import Field, Session, SQLModel + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +try: + from datetime import UTC +except ImportError: # Python 3.10 support + UTC = timezone.utc # noqa: UP017 + +REDACTED = "[REDACTED]" +_SECRET_KEY_FRAGMENTS = ( + "api_key", + "authorization", + "credential", + "password", + "private_key", + "secret", + "session_token", + "token", +) + + +class AuditOutcome(StrEnum): + """Result of an attempted state-changing operation.""" + + SUCCEEDED = "succeeded" + FAILED = "failed" + + +def _required_text(value: object, field_name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + normalized = value.strip() + if not normalized: + raise ValueError(f"{field_name} must be non-empty") + return normalized + + +def _optional_text(value: object | None, field_name: str) -> str | None: + return None if value is None else _required_text(value, field_name) + + +def _is_secret_key(key: object) -> bool: + normalized = str(key).strip().lower().replace("-", "_") + return any(fragment in normalized for fragment in _SECRET_KEY_FRAGMENTS) + + +def _redact(value: Any) -> Any: + if isinstance(value, Mapping): + return { + str(key): REDACTED if _is_secret_key(key) else _redact(item) + for key, item in value.items() + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [_redact(item) for item in value] + return value + + +def _json_payload(value: object) -> str: + try: + return json.dumps(_redact(value), sort_keys=True, separators=(",", ":")) + except (TypeError, ValueError) as exc: + raise ValueError("audit payload must be JSON-serializable") from exc + + +@dataclass(frozen=True) +class AuditEvent: + """Complete attribution contract for one attempted mutation.""" + + principal: Principal + action: str + target: str + reason: str + outcome: AuditOutcome + before: object + after: object + source: str + configuration_revision: str + correlation_id: str + error_code: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.principal, Principal): + raise TypeError("principal must be a Principal") + for field_name in ( + "action", + "target", + "reason", + "source", + "configuration_revision", + "correlation_id", + ): + object.__setattr__( + self, + field_name, + _required_text(getattr(self, field_name), field_name), + ) + if not isinstance(self.outcome, AuditOutcome): + raise TypeError("outcome must be an AuditOutcome") + object.__setattr__( + self, + "error_code", + _optional_text(self.error_code, "error_code"), + ) + _json_payload(self.before) + _json_payload(self.after) + + +class AuditLog(SQLModel, table=True): # type: ignore[call-arg] + """Immutable persisted representation of :class:`AuditEvent`.""" + + id: int | None = Field(default=None, primary_key=True) + actor_subject: str = Field(index=True) + actor_display_name: str + actor_role: str = Field(index=True) + action: str = Field(index=True) + target: str = Field(index=True) + reason: str + outcome: str = Field(index=True) + before_json: str + after_json: str + source: str + configuration_revision: str = Field(index=True) + correlation_id: str = Field(index=True) + error_code: str | None = Field(default=None) + timestamp: datetime = Field(default_factory=utc_now, index=True) + + +def append_audit_event(session: Session, event: AuditEvent) -> AuditLog: + """Append one audit row; the caller owns the surrounding transaction.""" + if not isinstance(session, Session): + raise TypeError("session must be a SQLModel Session") + if not isinstance(event, AuditEvent): + raise TypeError("event must be an AuditEvent") + row = AuditLog( + actor_subject=event.principal.subject, + actor_display_name=event.principal.display_name, + actor_role=event.principal.role.value, + action=event.action, + target=event.target, + reason=event.reason, + outcome=event.outcome.value, + before_json=_json_payload(event.before), + after_json=_json_payload(event.after), + source=event.source, + configuration_revision=event.configuration_revision, + correlation_id=event.correlation_id, + error_code=event.error_code, + timestamp=datetime.now(UTC), + ) + session.add(row) + session.flush() + return row + + +def install_append_only_guards(engine: Engine) -> None: + """Install idempotent database guards that reject audit mutation.""" + if not isinstance(engine, Engine): + raise TypeError("engine must be a SQLAlchemy Engine") + statements = ( + "CREATE TRIGGER IF NOT EXISTS auditlog_no_update " + "BEFORE UPDATE ON auditlog BEGIN " + "SELECT RAISE(ABORT, 'audit log is append-only'); END", + "CREATE TRIGGER IF NOT EXISTS auditlog_no_delete " + "BEFORE DELETE ON auditlog BEGIN " + "SELECT RAISE(ABORT, 'audit log is append-only'); END", + ) + with engine.begin() as connection: + for statement in statements: + connection.execute(text(statement)) diff --git a/src/p1am_control_system/backend/audit_middleware.py b/src/p1am_control_system/backend/audit_middleware.py new file mode 100644 index 0000000000..5a50ca3c0d --- /dev/null +++ b/src/p1am_control_system/backend/audit_middleware.py @@ -0,0 +1,149 @@ +"""Automatic append-only audit capture for every SCADA API mutation attempt.""" + +from __future__ import annotations + +import json +import logging +import uuid +from collections.abc import Callable + +from audit_log import AuditEvent, AuditOutcome, append_audit_event +from identity import Principal, Role +from sqlalchemy import Engine +from sqlmodel import Session +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import ASGIApp + +logger = logging.getLogger("dcs_backend.audit") + +MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) +DEFAULT_MAX_PAYLOAD_BYTES = 65_536 +_anonymous = Principal( + subject="unauthenticated.api", + display_name="Unauthenticated API Client", + role=Role.VIEWER, +) + +PrincipalResolver = Callable[[Request], Principal | None] +RevisionResolver = Callable[[], str] + + +def _is_mutation(request: Request) -> bool: + return request.method in MUTATION_METHODS and request.url.path.startswith("/api/") + + +def _request_payload(request: Request, body: bytes, maximum: int) -> object: + media_type = request.headers.get("content-type", "unknown").split(";", 1)[0] + if not body: + return {} + if len(body) > maximum: + return {"body_bytes": len(body), "media_type": media_type, "truncated": True} + if media_type == "application/json": + try: + return json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"body_bytes": len(body), "media_type": media_type, "invalid": True} + return {"body_bytes": len(body), "media_type": media_type} + + +class MutationAuditMiddleware(BaseHTTPMiddleware): + """Persist attributed, redacted audit rows without blocking plant controls.""" + + def __init__( + self, + app: ASGIApp, + engine: Engine, + principal_resolver: PrincipalResolver, + configuration_revision: RevisionResolver, + max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES, + ) -> None: + super().__init__(app) + if not isinstance(engine, Engine): + raise TypeError("engine must be a SQLAlchemy Engine") + if not callable(principal_resolver) or not callable(configuration_revision): + raise TypeError("audit resolvers must be callable") + if not isinstance(max_payload_bytes, int) or max_payload_bytes < 1: + raise ValueError("max_payload_bytes must be a positive integer") + self._engine = engine + self._principal_resolver = principal_resolver + self._configuration_revision = configuration_revision + self._max_payload_bytes = max_payload_bytes + + def _principal(self, request: Request) -> Principal: + try: + return self._principal_resolver(request) or _anonymous + except Exception as exc: # noqa: BLE001 - invalid auth must still be audited + logger.warning("Audit attribution failed closed: %s", type(exc).__name__) + return _anonymous + + def _revision(self) -> str: + try: + revision = self._configuration_revision().strip() + except Exception as exc: # noqa: BLE001 - audit must not block control + logger.warning("Audit revision lookup failed: %s", type(exc).__name__) + return "unknown" + return revision or "unknown" + + def _persist( + self, + request: Request, + body: bytes, + outcome: AuditOutcome, + error_code: str | None, + ) -> None: + client = request.client.host if request.client else "unknown" + event = AuditEvent( + principal=self._principal(request), + action=f"{request.method.lower()} {request.url.path}", + target=request.url.path, + reason=request.headers.get("X-Change-Reason") or "API mutation", + outcome=outcome, + before={}, + after={ + "request": _request_payload( + request, + body, + self._max_payload_bytes, + ) + }, + source=f"api:{client}", + configuration_revision=self._revision(), + correlation_id=( + request.headers.get("X-Correlation-ID") or str(uuid.uuid4()) + ), + error_code=error_code, + ) + try: + with Session(self._engine) as session: + append_audit_event(session, event) + session.commit() + except Exception as exc: # noqa: BLE001 - never obstruct a control action + logger.error("Audit persistence failed: %s", type(exc).__name__) + + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + if not _is_mutation(request): + return await call_next(request) + body = await request.body() + try: + response = await call_next(request) + except Exception: + self._persist(request, body, AuditOutcome.FAILED, "EXCEPTION") + raise + outcome = ( + AuditOutcome.SUCCEEDED + if response.status_code < 400 + else AuditOutcome.FAILED + ) + error_code = ( + None + if outcome is AuditOutcome.SUCCEEDED + else f"HTTP_{response.status_code}" + ) + self._persist(request, body, outcome, error_code) + return response diff --git a/src/p1am_control_system/backend/audit_router.py b/src/p1am_control_system/backend/audit_router.py new file mode 100644 index 0000000000..23abb21a40 --- /dev/null +++ b/src/p1am_control_system/backend/audit_router.py @@ -0,0 +1,112 @@ +"""Role-protected, paginated read API for the append-only audit trail.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import datetime +from typing import Annotated, Any + +from audit_log import AuditLog, AuditOutcome +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlmodel import Session, col, select + + +class AuditItem(BaseModel): + """Structured public representation of one immutable audit row.""" + + id: int + actor_subject: str + actor_display_name: str + actor_role: str + action: str + target: str + reason: str + outcome: AuditOutcome + before: Any + after: Any + source: str + configuration_revision: str + correlation_id: str + error_code: str | None + timestamp: datetime + + +class AuditPage(BaseModel): + """Bounded audit result page with continuation metadata.""" + + items: list[AuditItem] + limit: int + offset: int + has_more: bool + + +def _item(row: AuditLog) -> AuditItem: + if row.id is None: + raise ValueError("persisted audit row must have an id") + return AuditItem( + id=row.id, + actor_subject=row.actor_subject, + actor_display_name=row.actor_display_name, + actor_role=row.actor_role, + action=row.action, + target=row.target, + reason=row.reason, + outcome=AuditOutcome(row.outcome), + before=json.loads(row.before_json), + after=json.loads(row.after_json), + source=row.source, + configuration_revision=row.configuration_revision, + correlation_id=row.correlation_id, + error_code=row.error_code, + timestamp=row.timestamp, + ) + + +def create_audit_router( + get_session_dep: Callable[..., Session], + audit_auth_dep: Callable[..., object], +) -> APIRouter: + """Create the audit query router from injected persistence/auth boundaries.""" + if not callable(get_session_dep) or not callable(audit_auth_dep): + raise TypeError("audit router dependencies must be callable") + router = APIRouter( + prefix="/api/audit", + tags=["audit"], + dependencies=[Depends(audit_auth_dep)], + ) + + @router.get("") + async def query_audit( + session: Session = Depends(get_session_dep), # noqa: B008 + limit: Annotated[int, Query(ge=1, le=500)] = 100, + offset: Annotated[int, Query(ge=0)] = 0, + actor_subject: Annotated[str | None, Query(min_length=1)] = None, + outcome: AuditOutcome | None = None, + correlation_id: Annotated[str | None, Query(min_length=1)] = None, + ) -> AuditPage: + statement = select(AuditLog) + if actor_subject is not None: + statement = statement.where(AuditLog.actor_subject == actor_subject) + if outcome is not None: + statement = statement.where(AuditLog.outcome == outcome.value) + if correlation_id is not None: + statement = statement.where(AuditLog.correlation_id == correlation_id) + rows = list( + session.exec( + statement.order_by( + col(AuditLog.timestamp).desc(), col(AuditLog.id).desc() + ) + .offset(offset) + .limit(limit + 1) + ) + ) + return AuditPage( + items=[_item(row) for row in rows[:limit]], + limit=limit, + offset=offset, + has_more=len(rows) > limit, + ) + + return router diff --git a/src/p1am_control_system/backend/auth_config.py b/src/p1am_control_system/backend/auth_config.py index b13d393d82..5c6054e61a 100644 --- a/src/p1am_control_system/backend/auth_config.py +++ b/src/p1am_control_system/backend/auth_config.py @@ -32,9 +32,13 @@ import hmac import logging import os +from typing import Annotated from fastapi import HTTPException, Security, status -from fastapi.security import APIKeyHeader +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer +from identity import Principal, Role +from identity_config import EnvironmentIdentityProvider +from identity_router import IdentityService logger = logging.getLogger("dcs_backend.auth") @@ -43,6 +47,19 @@ # auto_error=False so we can return our own 401/503 with consistent messaging. _api_key_header = APIKeyHeader(name=CREDENTIAL_HEADER_NAME, auto_error=False) +_bearer = HTTPBearer(auto_error=False) +ApiKey = Annotated[str | None, Security(_api_key_header)] +BearerCredential = Annotated[ + HTTPAuthorizationCredentials | None, + Security(_bearer), +] + +_identity_provider = EnvironmentIdentityProvider(lambda: os.environ) +_development_principal = Principal( + subject="development.bypass", + display_name="Development Bypass", + role=Role.ADMIN, +) def _dev_no_auth() -> bool: @@ -71,18 +88,95 @@ def verify_operator_key(provided: str | None) -> bool: """ if _dev_no_auth(): return True - operator = _operator_key() - if operator is None: + try: + service = identity_service() + except (TypeError, ValueError): return False - if provided and _constant_time_eq(provided, operator): - return True - admin = _admin_key() - return bool(provided and admin and _constant_time_eq(provided, admin)) + if service is None: + return False + principal = service.resolve(provided, None) + return bool(principal and principal.allows(Role.OPERATOR)) + + +def identity_service() -> IdentityService | None: + """Return the stable configured identity service, if one exists.""" + return _identity_provider.get() + + +def resolve_optional_principal( + api_key: str | None, + authorization: str | None, +) -> Principal | None: + """Resolve request credentials for attribution without authorizing an action.""" + if _dev_no_auth(): + return _development_principal + try: + service = identity_service() + except (TypeError, ValueError): + return None + if service is None: + return None + bearer: HTTPAuthorizationCredentials | None = None + if authorization: + scheme, separator, credential = authorization.partition(" ") + if separator and scheme.lower() == "bearer" and credential: + bearer = HTTPAuthorizationCredentials( + scheme=scheme, + credentials=credential, + ) + return service.resolve(api_key, bearer) + + +def _unconfigured() -> HTTPException: + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "Server credential not configured. Set P1AM_PRINCIPALS_JSON or " + "P1AM_API_KEY/P1AM_ADMIN_API_KEY, or set P1AM_DEV_NO_AUTH=1 for " + "bench use." + ), + ) + + +def _resolve_principal( + api_key: str | None, + bearer: HTTPAuthorizationCredentials | None, +) -> Principal: + try: + service = identity_service() + except (TypeError, ValueError) as exc: + logger.error("Identity configuration is invalid: %s", type(exc).__name__) + raise _unconfigured() from exc + if service is None: + raise _unconfigured() + principal = service.resolve(api_key, bearer) + if principal is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid credential.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return principal + + +def _require_role( + required_role: Role, + api_key: str | None, + bearer: HTTPAuthorizationCredentials | None, +) -> Principal: + principal = _resolve_principal(api_key, bearer) + if not principal.allows(required_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"This operation requires the {required_role.value} role.", + ) + return principal def require_api_key( - api_key: str | None = Security(_api_key_header), -) -> None: + api_key: ApiKey = None, + bearer: BearerCredential = None, +) -> Principal: """FastAPI dependency enforcing a valid operator (or admin) API key. Raises: @@ -94,27 +188,27 @@ def require_api_key( "P1AM_DEV_NO_AUTH is enabled: API authentication is DISABLED. " "Do not use this in production." ) - return - if _operator_key() is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=( - "Server credential not configured. Set P1AM_API_KEY (and " - "optionally P1AM_ADMIN_API_KEY), or set P1AM_DEV_NO_AUTH=1 for " - "bench use." - ), - ) - if not verify_operator_key(api_key): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing or invalid API key.", - headers={"WWW-Authenticate": CREDENTIAL_HEADER_NAME}, + return _development_principal + return _require_role(Role.OPERATOR, api_key, bearer) + + +def require_engineer_key( + api_key: ApiKey = None, + bearer: BearerCredential = None, +) -> Principal: + """FastAPI dependency enforcing an engineer-or-higher named role.""" + if _dev_no_auth(): + logger.warning( + "P1AM_DEV_NO_AUTH is enabled: engineer authentication is DISABLED." ) + return _development_principal + return _require_role(Role.ENGINEER, api_key, bearer) def require_admin_key( - api_key: str | None = Security(_api_key_header), -) -> None: + api_key: ApiKey = None, + bearer: BearerCredential = None, +) -> Principal: """FastAPI dependency enforcing the elevated admin API key. If ``P1AM_ADMIN_API_KEY`` is set, only that key is accepted. Otherwise the @@ -123,34 +217,14 @@ def require_admin_key( """ if _dev_no_auth(): logger.warning("P1AM_DEV_NO_AUTH is enabled: admin authentication is DISABLED.") - return - - admin = _admin_key() - operator = _operator_key() - - if admin is None and operator is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=( - "Server credential not configured. Set P1AM_API_KEY/" - "P1AM_ADMIN_API_KEY, or set P1AM_DEV_NO_AUTH=1 for bench use." - ), - ) - - if admin is not None: - if api_key and _constant_time_eq(api_key, admin): - return - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="This operation requires the admin API key.", - headers={"WWW-Authenticate": CREDENTIAL_HEADER_NAME}, - ) - - # No admin key configured: accept the operator key. - if operator is not None and api_key and _constant_time_eq(api_key, operator): - return - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing or invalid API key.", - headers={"WWW-Authenticate": CREDENTIAL_HEADER_NAME}, - ) + return _development_principal + try: + return _require_role(Role.ADMIN, api_key, bearer) + except HTTPException as exc: + if exc.status_code == status.HTTP_401_UNAUTHORIZED and _admin_key() is not None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="This operation requires the admin role.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + raise diff --git a/src/p1am_control_system/backend/availability.py b/src/p1am_control_system/backend/availability.py new file mode 100644 index 0000000000..cd37ab9840 --- /dev/null +++ b/src/p1am_control_system/backend/availability.py @@ -0,0 +1,188 @@ +"""Single command authority, ordered buffering, recovery, and HMI-loss policy.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +def _synthetic(value: str) -> str: + if not value.startswith("SYNTHETIC."): + raise ValueError("identifiers must begin with SYNTHETIC.") + return value + + +class AvailabilityPolicy(BaseModel): + model_config = ConfigDict(frozen=True) + + recovery_time_objective: timedelta + recovery_point_objective: timedelta + max_clock_skew: timedelta + buffer_capacity: int = Field(gt=0) + + @model_validator(mode="after") + def _positive_contracts(self) -> AvailabilityPolicy: + if any( + value <= timedelta(0) + for value in ( + self.recovery_time_objective, + self.recovery_point_objective, + self.max_clock_skew, + ) + ): + raise ValueError("recovery and clock contracts must be positive") + return self + + +class AuthorityLease(BaseModel): + model_config = ConfigDict(frozen=True) + + lease_id: str + holder: str + + _holder_is_synthetic = field_validator("holder")(_synthetic) + + +class BufferedSample(BaseModel): + model_config = ConfigDict(frozen=True) + + sequence: int = Field(gt=0) + timestamp: datetime + value: float + + @field_validator("timestamp") + @classmethod + def _aware_timestamp(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("sample timestamp must include a UTC offset") + return value + + +class AvailabilityCommandResult(BaseModel): + model_config = ConfigDict(frozen=True) + + target: str + energizing: bool + accepted: bool + fail_closed: bool + reason: str + + +class AvailabilityHealth(BaseModel): + model_config = ConfigDict(frozen=True) + + recovery_time_objective_seconds: float + recovery_point_objective_seconds: float + clock_ordering_reliable: bool + command_authority: str | None + transport_available: bool + hmi_available: bool + buffered_samples: int + data_classification: Literal["synthetic"] = "synthetic" + + +class AvailabilityService: + def __init__(self, policy: AvailabilityPolicy) -> None: + self._policy = policy + self._authority: AuthorityLease | None = None + self._transport_available = True + self._hmi_available = True + self._clock_skew = timedelta(0) + self._buffer: list[BufferedSample] = [] + self._last_sequence = 0 + self._last_timestamp: datetime | None = None + + @property + def authority(self) -> AuthorityLease | None: + return self._authority + + def acquire_authority(self, holder: str) -> AuthorityLease: + if self._authority is not None: + raise PermissionError( + f"command authority is already held by {self._authority.holder}" + ) + lease = AuthorityLease(lease_id=uuid.uuid4().hex, holder=holder) + self._authority = lease + return lease + + def release_authority(self, lease_id: str) -> None: + if self._authority is None or self._authority.lease_id != lease_id: + raise PermissionError("only the active lease may release authority") + self._authority = None + + def set_transport_available(self, available: bool) -> None: + self._transport_available = available + + def ingest(self, sample: BufferedSample) -> None: + if sample.sequence <= self._last_sequence: + raise ValueError("sample sequences must strictly increase") + if ( + self._last_timestamp is not None + and sample.timestamp <= self._last_timestamp + ): + raise ValueError("sample timestamps must strictly increase") + if ( + not self._transport_available + and len(self._buffer) >= self._policy.buffer_capacity + ): + raise OverflowError("offline buffer capacity exceeded") + self._last_sequence = sample.sequence + self._last_timestamp = sample.timestamp + if not self._transport_available: + self._buffer.append(sample) + + def reconcile(self) -> list[BufferedSample]: + if not self._transport_available: + raise RuntimeError("transport must recover before reconciliation") + reconciled = list(self._buffer) + self._buffer.clear() + return reconciled + + def inject_fault(self, fault: Literal["hmi_unavailable", "authority_loss"]) -> None: + if fault == "hmi_unavailable": + self._hmi_available = False + elif fault == "authority_loss": + self._authority = None + + def report_clock_skew(self, skew: timedelta) -> None: + self._clock_skew = abs(skew) + + def command(self, target: str, *, energizing: bool) -> AvailabilityCommandResult: + target = _synthetic(target) + if self._authority is None: + return AvailabilityCommandResult( + target=target, + energizing=energizing, + accepted=False, + fail_closed=True, + reason="No command authority", + ) + if energizing and not self._hmi_available: + return AvailabilityCommandResult( + target=target, + energizing=True, + accepted=False, + fail_closed=True, + reason="Energizing commands are blocked while the HMI is unavailable", + ) + return AvailabilityCommandResult( + target=target, + energizing=energizing, + accepted=True, + fail_closed=False, + reason="Accepted by the single synthetic command authority", + ) + + def health(self) -> AvailabilityHealth: + return AvailabilityHealth( + recovery_time_objective_seconds=self._policy.recovery_time_objective.total_seconds(), + recovery_point_objective_seconds=self._policy.recovery_point_objective.total_seconds(), + clock_ordering_reliable=self._clock_skew <= self._policy.max_clock_skew, + command_authority=self._authority.holder if self._authority else None, + transport_available=self._transport_available, + hmi_available=self._hmi_available, + buffered_samples=len(self._buffer), + ) diff --git a/src/p1am_control_system/backend/configuration_repository.py b/src/p1am_control_system/backend/configuration_repository.py new file mode 100644 index 0000000000..016202f14a --- /dev/null +++ b/src/p1am_control_system/backend/configuration_repository.py @@ -0,0 +1,123 @@ +"""SQLite adapter for immutable configuration revision documents.""" + +from __future__ import annotations + +from collections.abc import Callable + +from configuration_workflow import ConfigurationRevision, ConfigurationState +from sqlalchemy import func +from sqlmodel import Field, Session, SQLModel, col, select + + +class ConfigurationRevisionRecord(SQLModel, table=True): # type: ignore[call-arg] + """Durable revision envelope; the JSON document is canonically validated.""" + + revision_id: str = Field(primary_key=True) + version: int = Field(index=True, unique=True) + state: str = Field(index=True) + payload_sha256: str = Field(index=True) + document_json: str + + +class SqliteRevisionRepository: + """Persist revision transitions without permitting payload identity rewrites.""" + + def __init__(self, session_factory: Callable[[], Session]) -> None: + if not callable(session_factory): + raise TypeError("session_factory must be callable") + self._session_factory = session_factory + + @staticmethod + def _record(revision: ConfigurationRevision) -> ConfigurationRevisionRecord: + return ConfigurationRevisionRecord( + revision_id=revision.revision_id, + version=revision.version, + state=revision.state.value, + payload_sha256=revision.payload_sha256, + document_json=revision.model_dump_json(), + ) + + @staticmethod + def _revision(record: ConfigurationRevisionRecord) -> ConfigurationRevision: + return ConfigurationRevision.model_validate_json(record.document_json) + + def next_version(self) -> int: + with self._session_factory() as session: + highest = session.exec( + select(func.max(ConfigurationRevisionRecord.version)) + ).one() + return int(highest or 0) + 1 + + def save(self, revision: ConfigurationRevision) -> None: + if not isinstance(revision, ConfigurationRevision): + raise TypeError("revision must be a ConfigurationRevision") + with self._session_factory() as session: + existing = session.get(ConfigurationRevisionRecord, revision.revision_id) + if existing is not None: + current = self._revision(existing) + if ( + current.payload_sha256 != revision.payload_sha256 + or current.payload != revision.payload + or current.version != revision.version + ): + raise ValueError( + "configuration revision payload identity is immutable" + ) + existing.state = revision.state.value + existing.document_json = revision.model_dump_json() + session.add(existing) + else: + session.add(self._record(revision)) + session.commit() + + def get(self, revision_id: str) -> ConfigurationRevision: + if not isinstance(revision_id, str) or not revision_id: + raise ValueError("revision_id must be a non-empty string") + with self._session_factory() as session: + record = session.get(ConfigurationRevisionRecord, revision_id) + if record is None: + raise KeyError(f"unknown configuration revision {revision_id!r}") + return self._revision(record) + + def list(self) -> list[ConfigurationRevision]: + with self._session_factory() as session: + records = session.exec( + select(ConfigurationRevisionRecord).order_by( + col(ConfigurationRevisionRecord.version) + ) + ).all() + return [self._revision(record) for record in records] + + def activate(self, revision: ConfigurationRevision) -> ConfigurationRevision: + if revision.state is not ConfigurationState.ACTIVE: + raise ValueError("activated revision must have active state") + with self._session_factory() as session: + target = session.get(ConfigurationRevisionRecord, revision.revision_id) + if target is None: + raise KeyError( + f"unknown configuration revision {revision.revision_id!r}" + ) + current_target = self._revision(target) + if ( + current_target.payload_sha256 != revision.payload_sha256 + or current_target.payload != revision.payload + ): + raise ValueError("configuration revision payload identity is immutable") + active_records = session.exec( + select(ConfigurationRevisionRecord).where( + ConfigurationRevisionRecord.state == ConfigurationState.ACTIVE.value + ) + ).all() + for record in active_records: + current = self._revision(record) + superseded = current.model_copy( + update={"state": ConfigurationState.SUPERSEDED} + ) + record.state = superseded.state.value + record.document_json = superseded.model_dump_json() + session.add(record) + target.state = revision.state.value + target.document_json = revision.model_dump_json() + session.add(target) + session.commit() + return revision diff --git a/src/p1am_control_system/backend/configuration_router.py b/src/p1am_control_system/backend/configuration_router.py new file mode 100644 index 0000000000..cd5088750e --- /dev/null +++ b/src/p1am_control_system/backend/configuration_router.py @@ -0,0 +1,143 @@ +"""Role-aware REST adapter for protected configuration revisions.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import cast + +from configuration_workflow import ( + ConfigurationDiff, + ConfigurationRevision, + ConfigurationWorkflow, +) +from fastapi import APIRouter, Depends, HTTPException, Query +from identity import Principal +from models import RoutingConfig +from pydantic import BaseModel, Field + + +class DraftRequest(BaseModel): + payload: RoutingConfig + reason: str = Field(min_length=1, max_length=500) + + +class ReasonRequest(BaseModel): + reason: str = Field(min_length=1, max_length=500) + + +def _domain_call( + operation: Callable[[], ConfigurationRevision], +) -> ConfigurationRevision: + try: + return operation() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +async def _async_domain_call( + operation: Callable[[], Awaitable[ConfigurationRevision]], +) -> ConfigurationRevision: + try: + return await operation() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +def create_configuration_router( + workflow: ConfigurationWorkflow, + engineer_dependency: Callable[..., Principal], + admin_dependency: Callable[..., Principal], +) -> APIRouter: + """Build the only public mutation path for protected configuration.""" + if not isinstance(workflow, ConfigurationWorkflow): + raise TypeError("workflow must be a ConfigurationWorkflow") + if not callable(engineer_dependency) or not callable(admin_dependency): + raise TypeError("configuration authorization dependencies must be callable") + router = APIRouter(prefix="/api/configurations", tags=["configuration"]) + + @router.get("") + async def revisions() -> list[ConfigurationRevision]: + return cast(list[ConfigurationRevision], workflow.list()) + + @router.get("/active") + async def active() -> ConfigurationRevision | None: + return workflow.active() + + @router.post("/drafts") + async def create_draft( + request: DraftRequest, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return _domain_call( + lambda: workflow.create_draft(request.payload, principal, request.reason) + ) + + @router.get("/{revision_id}") + async def get_revision(revision_id: str) -> ConfigurationRevision: + return _domain_call(lambda: workflow.get(revision_id)) + + @router.get("/{revision_id}/diff") + async def diff( + revision_id: str, + base_revision_id: str | None = Query(default=None), + ) -> list[ConfigurationDiff]: + try: + return cast( + list[ConfigurationDiff], + workflow.diff(revision_id, base_revision_id), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/{revision_id}/validate") + async def validate( + revision_id: str, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return _domain_call(lambda: workflow.validate(revision_id, principal)) + + @router.post("/{revision_id}/review") + async def review( + revision_id: str, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return _domain_call(lambda: workflow.submit_for_review(revision_id, principal)) + + @router.post("/{revision_id}/approve") + async def approve( + revision_id: str, + request: ReasonRequest, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return _domain_call( + lambda: workflow.approve(revision_id, principal, request.reason) + ) + + @router.post("/{revision_id}/activate") + async def activate( + revision_id: str, + principal: Principal = Depends(admin_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return await _async_domain_call( + lambda: workflow.activate(revision_id, principal) + ) + + @router.post("/{revision_id}/rollback") + async def rollback( + revision_id: str, + request: ReasonRequest, + principal: Principal = Depends(admin_dependency), # noqa: B008 + ) -> ConfigurationRevision: + return await _async_domain_call( + lambda: workflow.rollback(revision_id, principal, request.reason) + ) + + return router diff --git a/src/p1am_control_system/backend/configuration_workflow.py b/src/p1am_control_system/backend/configuration_workflow.py new file mode 100644 index 0000000000..c5ec5f309f --- /dev/null +++ b/src/p1am_control_system/backend/configuration_workflow.py @@ -0,0 +1,342 @@ +"""Canonical protected workflow for immutable SCADA configuration revisions.""" + +from __future__ import annotations + +import asyncio +import builtins +import hashlib +import json +import threading +from collections.abc import Awaitable, Callable, Mapping +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Protocol + +from alarm_service import manager_from_routing +from identity import Principal, Role +from models import RoutingConfig +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +class ConfigurationState(StrEnum): + DRAFT = "draft" + VALIDATED = "validated" + IN_REVIEW = "in_review" + APPROVED = "approved" + ACTIVE = "active" + SUPERSEDED = "superseded" + + +class ConfigurationDiff(BaseModel): + model_config = ConfigDict(frozen=True) + + path: str = Field(min_length=1) + before: object | None + after: object | None + + +class ConfigurationRevision(BaseModel): + """One immutable payload and its explicit workflow metadata.""" + + model_config = ConfigDict(frozen=True) + + revision_id: str + version: int = Field(gt=0) + state: ConfigurationState + payload: RoutingConfig + payload_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + reason: str + created_by: str + created_at: datetime + validated_by: str | None = None + reviewed_by: str | None = None + approved_by: str | None = None + activated_by: str | None = None + activated_at: datetime | None = None + activation_identity: str | None = None + source_revision_id: str | None = None + + +class RevisionRepository(Protocol): + def next_version(self) -> int: ... + def save(self, revision: ConfigurationRevision) -> None: ... + def get(self, revision_id: str) -> ConfigurationRevision: ... + def list(self) -> list[ConfigurationRevision]: ... + def activate(self, revision: ConfigurationRevision) -> ConfigurationRevision: ... + + +class InMemoryRevisionRepository: + """Deterministic repository used by tests and isolated demonstrations.""" + + def __init__(self) -> None: + self._revisions: dict[str, ConfigurationRevision] = {} + self._lock = threading.RLock() + + def next_version(self) -> int: + with self._lock: + return ( + max((item.version for item in self._revisions.values()), default=0) + 1 + ) + + def save(self, revision: ConfigurationRevision) -> None: + if not isinstance(revision, ConfigurationRevision): + raise TypeError("revision must be a ConfigurationRevision") + with self._lock: + self._revisions[revision.revision_id] = revision + + def get(self, revision_id: str) -> ConfigurationRevision: + with self._lock: + try: + return self._revisions[revision_id] + except KeyError as exc: + raise KeyError( + f"unknown configuration revision {revision_id!r}" + ) from exc + + def list(self) -> list[ConfigurationRevision]: + with self._lock: + return sorted(self._revisions.values(), key=lambda item: item.version) + + def activate(self, revision: ConfigurationRevision) -> ConfigurationRevision: + if revision.state is not ConfigurationState.ACTIVE: + raise ValueError("activated revision must have active state") + with self._lock: + for revision_id, current in tuple(self._revisions.items()): + if current.state is ConfigurationState.ACTIVE: + self._revisions[revision_id] = current.model_copy( + update={"state": ConfigurationState.SUPERSEDED} + ) + self._revisions[revision.revision_id] = revision + return revision + + +def _required_reason(reason: object) -> str: + if not isinstance(reason, str): + raise TypeError("reason must be a string") + normalized = reason.strip() + if not normalized: + raise ValueError("reason must be non-empty") + if len(normalized) > 500: + raise ValueError("reason must contain at most 500 characters") + return normalized + + +def _require_role(principal: Principal, role: Role) -> None: + if not isinstance(principal, Principal): + raise TypeError("principal must be a Principal") + if not principal.allows(role): + raise PermissionError(f"{role.value} role required") + + +def _payload_hash(payload: RoutingConfig) -> str: + canonical = json.dumps( + payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":") + ) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _flatten(value: object, prefix: str = "") -> dict[str, object]: + if isinstance(value, Mapping): + flattened: dict[str, object] = {} + for key in sorted(value): + path = f"{prefix}.{key}" if prefix else str(key) + flattened.update(_flatten(value[key], path)) + return flattened + if isinstance(value, list): + flattened = {} + for index, item in enumerate(value): + path = f"{prefix}.{index}" if prefix else str(index) + flattened.update(_flatten(item, path)) + return flattened + return {prefix: value} + + +class ConfigurationWorkflow: + """Application service enforcing every protected configuration transition.""" + + def __init__( + self, + repository: RevisionRepository, + deploy: Callable[[RoutingConfig], Awaitable[None]], + clock: Callable[[], datetime] | None = None, + ) -> None: + if not callable(deploy): + raise TypeError("deploy must be callable") + self._repository = repository + self._deploy = deploy + self._clock = clock or (lambda: datetime.now(UTC)) + self._mutation_lock = threading.RLock() + self._activation_lock = asyncio.Lock() + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + def get(self, revision_id: str) -> ConfigurationRevision: + return self._repository.get(revision_id) + + def list(self) -> list[ConfigurationRevision]: + return self._repository.list() + + def active(self) -> ConfigurationRevision | None: + return next( + ( + item + for item in reversed(self.list()) + if item.state is ConfigurationState.ACTIVE + ), + None, + ) + + def create_draft( + self, payload: RoutingConfig, principal: Principal, reason: str + ) -> ConfigurationRevision: + _require_role(principal, Role.ENGINEER) + if not isinstance(payload, RoutingConfig): + raise TypeError("payload must be a RoutingConfig") + with self._mutation_lock: + version = self._repository.next_version() + digest = _payload_hash(payload) + revision = ConfigurationRevision( + revision_id=f"cfg-{version:06d}-{digest[:12]}", + version=version, + state=ConfigurationState.DRAFT, + payload=payload.model_copy(deep=True), + payload_sha256=digest, + reason=_required_reason(reason), + created_by=principal.subject, + created_at=self._now(), + ) + self._repository.save(revision) + return revision + + def _transition( + self, + revision_id: str, + expected: ConfigurationState, + target: ConfigurationState, + **updates: object, + ) -> ConfigurationRevision: + with self._mutation_lock: + revision = self.get(revision_id) + if revision.state is not expected: + raise ValueError(f"revision must be {expected.value}") + # Annotated local: see the typing convention note in SPEC.md — CI runs + # mypy from the repo root, where flat intra-package imports become Any. + changed: ConfigurationRevision = revision.model_copy( + update={"state": target, **updates} + ) + self._repository.save(changed) + return changed + + def validate(self, revision_id: str, principal: Principal) -> ConfigurationRevision: + _require_role(principal, Role.ENGINEER) + revision = self.get(revision_id) + manager_from_routing(revision.payload) + return self._transition( + revision_id, + ConfigurationState.DRAFT, + ConfigurationState.VALIDATED, + validated_by=principal.subject, + ) + + def submit_for_review( + self, revision_id: str, principal: Principal + ) -> ConfigurationRevision: + _require_role(principal, Role.ENGINEER) + return self._transition( + revision_id, + ConfigurationState.VALIDATED, + ConfigurationState.IN_REVIEW, + reviewed_by=principal.subject, + ) + + def approve( + self, revision_id: str, principal: Principal, reason: str + ) -> ConfigurationRevision: + _require_role(principal, Role.ENGINEER) + _required_reason(reason) + return self._transition( + revision_id, + ConfigurationState.IN_REVIEW, + ConfigurationState.APPROVED, + approved_by=principal.subject, + ) + + def diff( + self, revision_id: str, base_revision_id: str | None = None + ) -> builtins.list[ConfigurationDiff]: + revision = self.get(revision_id) + base = self.get(base_revision_id) if base_revision_id else self.active() + before = _flatten(base.payload.model_dump(mode="json")) if base else {} + after = _flatten(revision.payload.model_dump(mode="json")) + return [ + ConfigurationDiff(path=path, before=before.get(path), after=after.get(path)) + for path in sorted(before.keys() | after.keys()) + if before.get(path) != after.get(path) + ] + + async def activate( + self, revision_id: str, principal: Principal + ) -> ConfigurationRevision: + _require_role(principal, Role.ADMIN) + async with self._activation_lock: + revision = self.get(revision_id) + if revision.state is not ConfigurationState.APPROVED: + raise ValueError("revision must be approved") + await self._deploy(revision.payload.model_copy(deep=True)) + active = revision.model_copy( + update={ + "state": ConfigurationState.ACTIVE, + "activated_by": principal.subject, + "activated_at": self._now(), + "activation_identity": revision.revision_id, + } + ) + return self._repository.activate(active) + + async def rollback( + self, + source_revision_id: str, + principal: Principal, + reason: str, + ) -> ConfigurationRevision: + _require_role(principal, Role.ADMIN) + source = self.get(source_revision_id) + if source.state not in { + ConfigurationState.ACTIVE, + ConfigurationState.SUPERSEDED, + }: + raise ValueError("rollback source must be active or superseded") + with self._mutation_lock: + version = self._repository.next_version() + clone = ConfigurationRevision( + revision_id=f"cfg-{version:06d}-{source.payload_sha256[:12]}", + version=version, + state=ConfigurationState.APPROVED, + payload=source.payload.model_copy(deep=True), + payload_sha256=source.payload_sha256, + reason=_required_reason(reason), + created_by=principal.subject, + created_at=self._now(), + validated_by=principal.subject, + reviewed_by=principal.subject, + approved_by=principal.subject, + source_revision_id=source.revision_id, + ) + self._repository.save(clone) + return await self.activate(clone.revision_id, principal) diff --git a/src/p1am_control_system/backend/connector_plugins.py b/src/p1am_control_system/backend/connector_plugins.py new file mode 100644 index 0000000000..3e6e2c4fd4 --- /dev/null +++ b/src/p1am_control_system/backend/connector_plugins.py @@ -0,0 +1,205 @@ +"""Isolated connector plugin contracts with fail-closed commands and redaction.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +_SECRET_FRAGMENTS = ("password", "secret", "token", "api_key", "credential") + + +def _synthetic(value: str) -> str: + normalized = value.strip() + if not normalized.startswith("SYNTHETIC."): + raise ValueError("connector and tag identifiers must begin with SYNTHETIC.") + return normalized + + +class ConnectorDescriptor(BaseModel): + model_config = ConfigDict(frozen=True) + + connector_id: str + version: str = Field(min_length=1, max_length=100) + tags: tuple[str, ...] = Field(min_length=1) + writable_tags: tuple[str, ...] = () + + _connector_is_synthetic = field_validator("connector_id")(_synthetic) + + @field_validator("tags", "writable_tags") + @classmethod + def _tags_are_synthetic(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(_synthetic(value) for value in values) + if len(normalized) != len(set(normalized)): + raise ValueError("connector tags must be unique") + return normalized + + @model_validator(mode="after") + def _read_write_tags_do_not_overlap(self) -> ConnectorDescriptor: + if set(self.tags) & set(self.writable_tags): + raise ValueError("read and writable tags must not overlap") + return self + + +class ConnectorPlugin(Protocol): + descriptor: ConnectorDescriptor + + def read(self) -> dict[str, float]: ... + + def write(self, tag: str, value: float) -> None: ... + + def diagnostics(self) -> dict[str, object]: ... + + +class ConnectorSample(BaseModel): + model_config = ConfigDict(frozen=True) + + value: float | None + quality: Literal["good", "bad"] + diagnostic: str + connector_id: str + + +class CommandDisposition(StrEnum): + ACCEPTED = "accepted" + REJECTED = "rejected" + + +class ConnectorCommandResult(BaseModel): + model_config = ConfigDict(frozen=True) + + tag: str + connector_id: str | None + disposition: CommandDisposition + fail_closed: bool + diagnostic: str + + +class ConnectorDiagnostic(BaseModel): + model_config = ConfigDict(frozen=True) + + connector_id: str + version: str + details: dict[str, object] + + +def _redact(details: Mapping[str, object]) -> dict[str, object]: + return { + key: ( + "[REDACTED]" + if any(fragment in key.casefold() for fragment in _SECRET_FRAGMENTS) + else value + ) + for key, value in details.items() + } + + +class ConnectorManager: + def __init__(self, connectors: Sequence[ConnectorPlugin]) -> None: + self._connectors = tuple(connectors) + connector_ids = [item.descriptor.connector_id for item in self._connectors] + if len(connector_ids) != len(set(connector_ids)): + raise ValueError("connector identifiers must be unique") + all_tags = [ + tag + for item in self._connectors + for tag in (*item.descriptor.tags, *item.descriptor.writable_tags) + ] + if len(all_tags) != len(set(all_tags)): + raise ValueError("tags may belong to only one connector") + self._writers = { + tag: connector + for connector in self._connectors + for tag in connector.descriptor.writable_tags + } + + def poll(self) -> dict[str, ConnectorSample]: + samples: dict[str, ConnectorSample] = {} + for connector in self._connectors: + descriptor = connector.descriptor + try: + values = connector.read() + if set(values) != set(descriptor.tags): + raise ValueError("connector returned an unexpected tag set") + for tag, value in values.items(): + if not math.isfinite(value): + raise ValueError("connector returned a non-finite value") + samples[tag] = ConnectorSample( + value=value, + quality="good", + diagnostic="", + connector_id=descriptor.connector_id, + ) + except ( + Exception + ) as exc: # Connector boundary intentionally isolates plugins. + diagnostic = ( + f"{descriptor.connector_id} read failed ({type(exc).__name__})" + ) + for tag in descriptor.tags: + samples[tag] = ConnectorSample( + value=None, + quality="bad", + diagnostic=diagnostic, + connector_id=descriptor.connector_id, + ) + return samples + + def command(self, tag: str, value: float) -> ConnectorCommandResult: + connector = self._writers.get(tag) + if connector is None: + return ConnectorCommandResult( + tag=tag, + connector_id=None, + disposition=CommandDisposition.REJECTED, + fail_closed=True, + diagnostic="No connector owns this writable tag", + ) + try: + if not math.isfinite(value): + raise ValueError("command value must be finite") + connector.write(tag, value) + except Exception as exc: # Connector boundary intentionally isolates plugins. + return ConnectorCommandResult( + tag=tag, + connector_id=connector.descriptor.connector_id, + disposition=CommandDisposition.REJECTED, + fail_closed=True, + diagnostic=( + f"{connector.descriptor.connector_id} command failed " + f"({type(exc).__name__})" + ), + ) + return ConnectorCommandResult( + tag=tag, + connector_id=connector.descriptor.connector_id, + disposition=CommandDisposition.ACCEPTED, + fail_closed=False, + diagnostic="", + ) + + def diagnostics(self) -> list[ConnectorDiagnostic]: + results: list[ConnectorDiagnostic] = [] + for connector in self._connectors: + try: + details = _redact(connector.diagnostics()) + except Exception as exc: + details = {"error": f"diagnostics failed ({type(exc).__name__})"} + results.append( + ConnectorDiagnostic( + connector_id=connector.descriptor.connector_id, + version=connector.descriptor.version, + details=details, + ) + ) + return results diff --git a/src/p1am_control_system/backend/database.py b/src/p1am_control_system/backend/database.py index 9e70e5e08d..0729bb1861 100644 --- a/src/p1am_control_system/backend/database.py +++ b/src/p1am_control_system/backend/database.py @@ -2,6 +2,8 @@ from collections.abc import Generator from typing import Any +from audit_log import install_append_only_guards +from configuration_repository import ConfigurationRevisionRecord # noqa: F401 from settings import P1AMSettings, get_settings from sqlalchemy import event from sqlmodel import Session, SQLModel, create_engine @@ -80,6 +82,8 @@ def init_db() -> None: """ try: SQLModel.metadata.create_all(engine) + install_append_only_guards(engine) + _migrate_historian_quality_columns() _migrate_historian_indexes() _optimize_planner_statistics() logger.info("Database tables initialized successfully.") @@ -88,6 +92,43 @@ def init_db() -> None: raise +def _migrate_historian_quality_columns() -> None: + """Add signal provenance columns without discarding legacy historian rows.""" + from sqlalchemy import text + + definitions = { + "source_timestamp": "DATETIME", + "quality": "VARCHAR NOT NULL DEFAULT 'uncertain'", + "diagnostic_reason": "VARCHAR DEFAULT 'legacy_unqualified'", + "sequence": "INTEGER NOT NULL DEFAULT 0", + "source": "VARCHAR NOT NULL DEFAULT 'legacy.adapter'", + } + with engine.begin() as connection: + columns = { + row[1] for row in connection.exec_driver_sql("PRAGMA table_info(taglog)") + } + for name, definition in definitions.items(): + if name not in columns: + connection.execute( + text(f"ALTER TABLE taglog ADD COLUMN {name} {definition}") + ) + connection.execute( + text( + "UPDATE taglog SET source_timestamp = timestamp " + "WHERE source_timestamp IS NULL" + ) + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_taglog_quality ON taglog (quality)") + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_taglog_sequence ON taglog (sequence)") + ) + connection.execute( + text("CREATE INDEX IF NOT EXISTS ix_taglog_source ON taglog (source)") + ) + + def _migrate_historian_indexes() -> None: """Ensure the composite trend-query index exists and reclaim WAL space. diff --git a/src/p1am_control_system/backend/enum_compat.py b/src/p1am_control_system/backend/enum_compat.py new file mode 100644 index 0000000000..d0730a212e --- /dev/null +++ b/src/p1am_control_system/backend/enum_compat.py @@ -0,0 +1,45 @@ +"""Python-version compatibility for :class:`enum.StrEnum` in this package. + +``enum.StrEnum`` is new in Python 3.11, and the CI test matrix still runs 3.10. +An unguarded ``from enum import StrEnum`` therefore raises +``ImportError: cannot import name 'StrEnum' from 'enum'`` on 3.10 — and because +``main`` imports the SCADA modules transitively, a single unguarded import there +aborts collection of every test module that imports the app, not just the one +that owns the enum. + +This mirrors ``src/shared/python/compatibility.py``, which already provides the +same backport for the shared tree. It is duplicated here rather than imported +because this package deliberately uses flat intra-package imports (``import +historian``, ``from signal_quality import SignalFrame``) and is run with the +backend directory on ``sys.path``, so ``shared.python.compatibility`` is not +importable from it. + +The ``TYPE_CHECKING`` branch keeps type checkers on the real 3.11 symbol, so the +backport never weakens inference. +""" + +from __future__ import annotations + +import sys +from enum import Enum +from typing import TYPE_CHECKING + +__all__ = ["StrEnum"] + +if TYPE_CHECKING: + from enum import StrEnum +elif sys.version_info >= (3, 11): # noqa: UP036 + from enum import StrEnum +else: + + class StrEnum(str, Enum): # noqa: UP042 + """Backport of :class:`enum.StrEnum` for Python 3.10. + + Subclassing ``str`` makes members compare equal to their values, and the + explicit ``__str__`` keeps ``str(member)`` as the value rather than + ``"Class.MEMBER"`` — the behaviour 3.11's ``StrEnum`` guarantees and + which JSON payloads and audit records in this package rely on. + """ + + def __str__(self) -> str: + return str(self.value) diff --git a/src/p1am_control_system/backend/evidence_package.py b/src/p1am_control_system/backend/evidence_package.py new file mode 100644 index 0000000000..98a3019df5 --- /dev/null +++ b/src/p1am_control_system/backend/evidence_package.py @@ -0,0 +1,105 @@ +"""Self-contained ZIP adapter for synthetic acceptance evidence.""" + +from __future__ import annotations + +import io +import zipfile +from dataclasses import dataclass, field +from typing import Literal + +from pydantic import BaseModel, ConfigDict +from scenario_evidence import ( + ScenarioDefinition, + ScenarioEvidence, + canonical_model_bytes, + sha256_bytes, +) + +PACKAGE_SCHEMA = "p1am.acceptance-package/v1" +PACKAGE_ENTRIES = frozenset({"manifest.json", "scenario.json", "evidence.json"}) +MAX_PACKAGE_BYTES = 5_000_000 + + +class EvidencePackageManifest(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: Literal["p1am.acceptance-package/v1"] = "p1am.acceptance-package/v1" + evidence_id: str + data_classification: Literal["synthetic"] = "synthetic" + not_for_live_control: Literal[True] = True + entries: dict[str, str] + + +@dataclass(frozen=True) +class EvidenceArtifact: + payload: bytes = field(repr=False) + sha256: str + manifest: EvidencePackageManifest + + +@dataclass(frozen=True) +class VerifiedEvidencePackage: + manifest: EvidencePackageManifest + scenario: ScenarioDefinition + evidence: ScenarioEvidence + package_sha256: str + + +class EvidencePackageService: + def create( + self, scenario: ScenarioDefinition, evidence: ScenarioEvidence + ) -> EvidenceArtifact: + scenario_payload = canonical_model_bytes(scenario) + evidence_payload = canonical_model_bytes(evidence) + if evidence.scenario_sha256 != sha256_bytes(scenario_payload): + raise ValueError("evidence does not identify the supplied scenario") + manifest = EvidencePackageManifest( + evidence_id=evidence.evidence_id, + entries={ + "scenario.json": sha256_bytes(scenario_payload), + "evidence.json": sha256_bytes(evidence_payload), + }, + ) + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", manifest.model_dump_json(indent=2)) + archive.writestr("scenario.json", scenario_payload) + archive.writestr("evidence.json", evidence_payload) + payload = output.getvalue() + return EvidenceArtifact(payload, sha256_bytes(payload), manifest) + + def verify( + self, payload: bytes, expected_sha256: str | None = None + ) -> VerifiedEvidencePackage: + if ( + not isinstance(payload, bytes) + or not payload + or len(payload) > MAX_PACKAGE_BYTES + ): + raise ValueError("evidence package size is outside the allowed boundary") + package_sha = sha256_bytes(payload) + if expected_sha256 is not None and package_sha != expected_sha256.lower(): + raise ValueError("evidence package checksum does not match") + try: + with zipfile.ZipFile(io.BytesIO(payload), "r") as archive: + if frozenset(archive.namelist()) != PACKAGE_ENTRIES: + raise ValueError("evidence package entries are not allowed") + manifest_payload = archive.read("manifest.json") + scenario_payload = archive.read("scenario.json") + evidence_payload = archive.read("evidence.json") + except (zipfile.BadZipFile, RuntimeError) as exc: + raise ValueError("evidence package is not a valid archive") from exc + manifest = EvidencePackageManifest.model_validate_json(manifest_payload) + for name, content in ( + ("scenario.json", scenario_payload), + ("evidence.json", evidence_payload), + ): + if manifest.entries.get(name) != sha256_bytes(content): + raise ValueError(f"{name} checksum does not match") + scenario = ScenarioDefinition.model_validate_json(scenario_payload) + evidence = ScenarioEvidence.model_validate_json(evidence_payload) + if evidence.scenario_sha256 != sha256_bytes(canonical_model_bytes(scenario)): + raise ValueError("evidence scenario identity does not match") + if evidence.evidence_id != manifest.evidence_id: + raise ValueError("evidence identity does not match the manifest") + return VerifiedEvidencePackage(manifest, scenario, evidence, package_sha) diff --git a/src/p1am_control_system/backend/historian.py b/src/p1am_control_system/backend/historian.py index 3122343729..bfdb3d957f 100644 --- a/src/p1am_control_system/backend/historian.py +++ b/src/p1am_control_system/backend/historian.py @@ -15,6 +15,7 @@ from datetime import datetime, timezone from models import TagLog +from signal_quality import SignalFrame try: from datetime import UTC @@ -29,6 +30,7 @@ def log_scan( tags: dict[str, float], *, timestamp: datetime | None = None, + signal_frame: SignalFrame | None = None, ) -> int: """Bulk-insert one scan's tag samples; return the number of rows written. @@ -53,11 +55,20 @@ def log_scan( raise TypeError(f"tags must be a dict, got {type(tags).__name__}") if timestamp is not None and not isinstance(timestamp, datetime): raise TypeError(f"timestamp must be a datetime or None, got {type(timestamp)}") + if signal_frame is not None and not isinstance(signal_frame, SignalFrame): + raise TypeError("signal_frame must be a SignalFrame or None") if not tags: return 0 - ts = timestamp if timestamp is not None else datetime.now(UTC) + if signal_frame is not None: + if signal_frame.values != {name: float(value) for name, value in tags.items()}: + raise ValueError("signal_frame values must match logged tags") + if timestamp is not None and timestamp != signal_frame.server_timestamp: + raise ValueError("timestamp must match signal_frame server_timestamp") + ts = signal_frame.server_timestamp + else: + ts = timestamp if timestamp is not None else datetime.now(UTC) rows = [] for name, value in tags.items(): @@ -65,7 +76,33 @@ def log_scan( numeric = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"tag {name!r} has non-numeric value {value!r}") from exc - rows.append({"tag_name": str(name), "value": numeric, "timestamp": ts}) + if signal_frame is None: + rows.append( + { + "tag_name": str(name), + "value": numeric, + "source_timestamp": ts, + "timestamp": ts, + "quality": "uncertain", + "diagnostic_reason": "legacy_unqualified", + "sequence": 0, + "source": "legacy.adapter", + } + ) + continue + sample = signal_frame.samples[str(name)] + rows.append( + { + "tag_name": str(name), + "value": numeric, + "source_timestamp": sample.source_timestamp, + "timestamp": sample.server_timestamp, + "quality": sample.quality.value, + "diagnostic_reason": sample.diagnostic_reason, + "sequence": sample.sequence, + "source": sample.source, + } + ) session.execute(insert(TagLog), rows) return len(rows) diff --git a/src/p1am_control_system/backend/historian_shipper.py b/src/p1am_control_system/backend/historian_shipper.py new file mode 100644 index 0000000000..eb5d6a8b7b --- /dev/null +++ b/src/p1am_control_system/backend/historian_shipper.py @@ -0,0 +1,421 @@ +"""Store-and-forward shipping of historian samples to a remote plant historian. + +One responsibility: get samples off the control node without ever letting the +remote destination influence the control node's timing. + +Why a thread and not a coroutine +-------------------------------- +``_poll_once`` calls the historian write path synchronously from inside the +async scan. Doing remote I/O there — even awaited — puts network latency on the +scan budget. At 10 Hz a single 2 s TCP timeout costs 20 scans and stalls the HMI +broadcast, alarm evaluation, and the E-stop re-engage path. That is a safety +regression, not a performance one. + +So the producer (the scan loop) only ever does a bounded, non-blocking +``put_nowait`` onto an in-memory queue, and a daemon thread owns every socket +operation. The scan loop cannot block on the network by construction. + +Delivery guarantees +------------------- +**At-most-once, and deliberately so.** The queue is in memory only; a process +restart discards whatever had not shipped. This is acceptable because SQLite +remains the authoritative local store — a restart loses *forwarding*, never +*data*. Backfilling the remote from SQLite is a separate concern and is not +attempted here. Do not build anything on an assumption of exactly-once. + +Under sustained backpressure the queue drops the **oldest** samples. For process +history the newest data is the operationally useful data, and an unbounded queue +on a Pi with a gigabyte free is an out-of-memory crash of the control node — +which is a far worse outcome than a gap in a trend. +""" + +from __future__ import annotations + +import logging +import queue +import random +import threading +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Protocol, runtime_checkable + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +__all__ = [ + "RemoteHistorianWriter", + "Sample", + "ShipperStats", + "StoreAndForwardSink", +] + +logger = logging.getLogger("dcs_backend.historian_shipper") + +# A single measurement: when, which tag, what value. +Sample = tuple[datetime, str, float] + +# Ceiling on reconnect backoff. Long enough that a historian down overnight +# costs ~120 reconnect attempts rather than ~29000, short enough that recovery +# after a transient blip is felt within a scan-or-two of operator patience. +_MAX_BACKOFF_S = 30.0 +_INITIAL_BACKOFF_S = 0.5 + + +@runtime_checkable +class RemoteHistorianWriter(Protocol): + """The network-facing half of the shipper, owned entirely by the worker. + + Implementations are only ever touched from the shipper's worker thread, so + they do not need to be thread-safe. + """ + + def connect(self) -> None: + """Establish the connection. May raise; the shipper will back off.""" + ... + + def write_batch(self, samples: Sequence[Sample]) -> int: + """Persist a batch. May raise; the shipper will reconnect and retry.""" + ... + + def close(self) -> None: + """Release resources. Must be idempotent and must not raise.""" + ... + + +@dataclass(frozen=True) +class ShipperStats: + """Point-in-time snapshot of shipper health. + + Exposed so a gap in a Grafana trend can be diagnosed as a *forwarding* gap + rather than misread as a real process measurement — a flat line that is + actually missing data is a genuine hazard for anyone reading a trend. + """ + + enabled: bool + connected: bool + queue_depth: int + queue_max: int + shipped_total: int + dropped_total: int + consecutive_failures: int + last_success_ts: datetime | None = None + lag_s: float | None = None + last_error: str | None = None + + def as_dict(self) -> dict[str, object]: + """JSON-serialisable form for the health endpoint.""" + return { + "enabled": self.enabled, + "connected": self.connected, + "queue_depth": self.queue_depth, + "queue_max": self.queue_max, + "shipped_total": self.shipped_total, + "dropped_total": self.dropped_total, + "consecutive_failures": self.consecutive_failures, + "last_success_ts": ( + self.last_success_ts.isoformat() if self.last_success_ts else None + ), + "lag_s": self.lag_s, + "last_error": self.last_error, + } + + +@dataclass +class _Counters: + """Mutable counters with a single writer thread each. + + ``dropped`` is written only by the producer (scan loop); ``shipped``, + ``last_success``, ``failures``, ``connected`` and ``last_error`` only by the + worker. Single-writer means ``+=`` needs no lock, and a reader tolerating a + momentarily stale value is exactly what a health endpoint wants. This keeps + the 10 Hz enqueue path free of lock contention. + """ + + dropped: int = 0 + shipped: int = 0 + failures: int = 0 + connected: bool = False + last_success: datetime | None = None + last_error: str | None = None + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + +class StoreAndForwardSink: + """A :class:`~historian_sink.HistorianSink` that forwards over the network. + + Satisfies the sink contract: :meth:`write_scan` never blocks on I/O, never + raises in steady state, and may drop under backpressure. + """ + + def __init__( + self, + writer: RemoteHistorianWriter, + *, + queue_max: int = 100_000, + batch_size: int = 1_000, + flush_interval_s: float = 1.0, + jitter: Callable[[], float] = random.random, + ) -> None: + """Build a shipper. Call :meth:`start` to run it. + + Args: + writer: The remote destination. Owned by the worker thread. + queue_max: Bounded queue depth. Overflow drops oldest. + batch_size: Maximum samples per remote round-trip. + flush_interval_s: Maximum time a partial batch waits before shipping. + jitter: Returns a value in [0, 1) used to spread reconnect attempts. + + Raises: + TypeError: If ``writer`` does not implement + :class:`RemoteHistorianWriter`, or a numeric argument is not + numeric. + ValueError: If ``queue_max`` or ``batch_size`` is < 1, or + ``flush_interval_s`` is not positive and finite. + """ + if not isinstance(writer, RemoteHistorianWriter): + raise TypeError( + "writer must implement RemoteHistorianWriter, " + f"got {type(writer).__name__}" + ) + queue_max = _positive_int("queue_max", queue_max) + batch_size = _positive_int("batch_size", batch_size) + flush_interval_s = _positive_float("flush_interval_s", flush_interval_s) + if not callable(jitter): + raise TypeError(f"jitter must be callable, got {type(jitter).__name__}") + + self._writer = writer + self._queue: queue.Queue[Sample] = queue.Queue(maxsize=queue_max) + self._queue_max = queue_max + self._batch_size = batch_size + self._flush_interval_s = flush_interval_s + self._jitter = jitter + + self._counters = _Counters() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + # ---------------------------------------------------------------- lifecycle + + def start(self) -> None: + """Start the worker thread. Idempotent.""" + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, + name="historian-shipper", + daemon=True, + ) + self._thread.start() + logger.info( + "Historian shipper started (queue_max=%d, batch_size=%d)", + self._queue_max, + self._batch_size, + ) + + def close(self, *, timeout_s: float = 5.0) -> None: + """Stop the worker and release the remote connection. + + Bounded by ``timeout_s`` so application shutdown can never hang on an + unreachable historian. Idempotent; never raises. + """ + self._stop.set() + thread = self._thread + if thread is not None and thread.is_alive(): + thread.join(timeout=timeout_s) + if thread.is_alive(): + logger.warning( + "Historian shipper did not stop within %.1fs; " + "abandoning %d queued samples", + timeout_s, + self._queue.qsize(), + ) + self._thread = None + try: + self._writer.close() + except Exception: # noqa: BLE001 - shutdown must not fail on the remote + logger.debug("Remote historian close failed", exc_info=True) + + # ------------------------------------------------------------- sink surface + + def write_scan(self, tags: Mapping[str, float], timestamp: datetime) -> int: + """Enqueue one scan's samples. Non-blocking; drops oldest when full. + + Args: + tags: Mapping of tag name -> value. + timestamp: Shared sample time for the scan. + + Returns: + Number of samples enqueued (may be less than ``len(tags)`` only if + a value was non-finite and skipped). + """ + accepted = 0 + for name, value in tags.items(): + try: + numeric = float(value) + except (TypeError, ValueError): + # A non-numeric tag is a local-historian problem and is already + # rejected there with a hard error. Forwarding just skips it + # rather than taking down the scan a second time. + continue + if not self._enqueue((timestamp, str(name), numeric)): + continue + accepted += 1 + return accepted + + def _enqueue(self, sample: Sample) -> bool: + """Put with drop-oldest overflow. Never blocks, never raises.""" + try: + self._queue.put_nowait(sample) + return True + except queue.Full: + pass + + # Full: evict the oldest to make room. The get/put pair is not atomic, + # but the only other consumer is the worker, which can only make more + # room. Worst case the put still fails and we count a drop. + try: + self._queue.get_nowait() + self._counters.dropped += 1 + except queue.Empty: + pass + try: + self._queue.put_nowait(sample) + return True + except queue.Full: + self._counters.dropped += 1 + return False + + # ------------------------------------------------------------------- worker + + def _run(self) -> None: + """Worker loop: connect, drain, ship, back off on failure.""" + backoff = _INITIAL_BACKOFF_S + while not self._stop.is_set(): + if not self._counters.connected: + if not self._try_connect(): + # Sleep on the stop event so shutdown is immediate rather + # than waiting out a 30 s backoff. + self._stop.wait(backoff * (0.5 + self._jitter())) + backoff = min(backoff * 2.0, _MAX_BACKOFF_S) + continue + backoff = _INITIAL_BACKOFF_S + + batch = self._collect_batch() + if not batch: + continue + if not self._ship(batch): + self._stop.wait(backoff * (0.5 + self._jitter())) + backoff = min(backoff * 2.0, _MAX_BACKOFF_S) + + # Final best-effort flush of whatever is already queued. + if self._counters.connected: + final = self._collect_batch(blocking=False) + if final: + self._ship(final) + + def _try_connect(self) -> bool: + try: + self._writer.connect() + except Exception as exc: # noqa: BLE001 - any failure is a retry + self._counters.failures += 1 + self._counters.last_error = f"{type(exc).__name__}: {exc}" + # Rate-limited: only the first failure of an outage and then every + # 10th, so a historian down overnight does not fill the Pi's disk + # with identical log lines at 10 Hz. + if self._counters.failures == 1 or self._counters.failures % 10 == 0: + logger.warning( + "Historian shipper cannot connect (attempt %d): %s", + self._counters.failures, + exc, + ) + return False + self._counters.connected = True + logger.info("Historian shipper connected") + return True + + def _collect_batch(self, *, blocking: bool = True) -> list[Sample]: + """Gather up to ``batch_size`` samples, waiting at most one interval.""" + batch: list[Sample] = [] + if blocking: + try: + batch.append(self._queue.get(timeout=self._flush_interval_s)) + except queue.Empty: + return batch + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + return batch + + def _ship(self, batch: list[Sample]) -> bool: + """Write one batch. On failure, mark disconnected and report.""" + try: + self._writer.write_batch(batch) + except Exception as exc: # noqa: BLE001 - any failure is a reconnect + self._counters.connected = False + self._counters.failures += 1 + self._counters.last_error = f"{type(exc).__name__}: {exc}" + if self._counters.failures == 1 or self._counters.failures % 10 == 0: + logger.warning( + "Historian shipper failed to write %d samples " + "(failure %d); dropping batch: %s", + len(batch), + self._counters.failures, + exc, + ) + # The batch is discarded rather than retried. Retrying in place + # would stall the drain and let the queue overflow into dropping + # *newer* data to preserve data we already know we cannot deliver. + self._counters.dropped += len(batch) + try: + self._writer.close() + except Exception: # noqa: BLE001 + logger.debug("Remote close during error recovery failed", exc_info=True) + return False + + self._counters.shipped += len(batch) + self._counters.last_success = datetime.now(UTC) + self._counters.failures = 0 + self._counters.last_error = None + return True + + # -------------------------------------------------------------- diagnostics + + def stats(self) -> ShipperStats: + """Snapshot shipper health. Safe to call from any thread.""" + last = self._counters.last_success + lag = (datetime.now(UTC) - last).total_seconds() if last else None + return ShipperStats( + enabled=True, + connected=self._counters.connected, + queue_depth=self._queue.qsize(), + queue_max=self._queue_max, + shipped_total=self._counters.shipped, + dropped_total=self._counters.dropped, + consecutive_failures=self._counters.failures, + last_success_ts=last, + lag_s=lag, + last_error=self._counters.last_error, + ) + + +def _positive_int(name: str, value: object) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError(f"{name} must be an int, got {type(value).__name__}") + if value < 1: + raise ValueError(f"{name} must be >= 1, got {value}") + return value + + +def _positive_float(name: str, value: object) -> float: + if not isinstance(value, int | float) or isinstance(value, bool): + raise TypeError(f"{name} must be numeric, got {type(value).__name__}") + v = float(value) + if v <= 0.0 or v != v or v == float("inf"): + raise ValueError(f"{name} must be positive and finite, got {value!r}") + return v diff --git a/src/p1am_control_system/backend/historian_sink.py b/src/p1am_control_system/backend/historian_sink.py new file mode 100644 index 0000000000..36b9508547 --- /dev/null +++ b/src/p1am_control_system/backend/historian_sink.py @@ -0,0 +1,223 @@ +"""Pluggable historian write backends. + +One responsibility: define the seam between "a scan happened" and "somewhere +durable learned about it", so the local SQLite historian and a remote plant +historian (TimescaleDB) can both be fed without either knowing about the other. + +Design constraint that shapes this module +----------------------------------------- +``poll_runtime._poll_once`` writes historian rows *and* alarm-event rows on one +SQLAlchemy session and commits them together. That shared commit is deliberate: +it makes a scan atomic in the local database, so a crash can never leave an +alarm event without the sample that triggered it. A sink that owned its own +session would silently break that atomicity. + +So the split is: + +* The **local** write stays exactly where it is, on the caller's session, via + :func:`historian.log_scan`. It is the source of truth and cannot be skipped. +* A :class:`HistorianSink` is a **forwarding** interface only. It receives a + copy of the scan and is free to be remote, queued, lossy, or absent. It is + never permitted to affect the local write or the poll loop. + +:class:`HistorianWriter` composes the two behind the exact callable shape +``_poll_once`` already expects, so the control path is untouched. + +LOD: this module imports only ``historian``, the ``signal_quality`` value type, +and stdlib — nothing from FastAPI, the PLC clients, or the database engine — so +it unit-tests against a plain session double. (``historian`` itself imports +``signal_quality``, so this adds no new dependency edge to the package.) +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Mapping +from datetime import datetime, timezone +from typing import Protocol, runtime_checkable + +import historian + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from signal_quality import SignalFrame +from sqlmodel import Session + +__all__ = [ + "HistorianSink", + "HistorianWriter", + "NullHistorianSink", +] + +logger = logging.getLogger("dcs_backend.historian_sink") + + +@runtime_checkable +class HistorianSink(Protocol): + """A best-effort destination for forwarded scan samples. + + Implementations MUST treat every method as non-throwing from the caller's + perspective in steady state, and MUST NOT block for longer than a scan + period. A sink that needs to do network I/O is expected to enqueue and + return, not to perform the I/O inline (see + :mod:`historian_shipper`). + + Implementations MAY drop samples under backpressure. Loss of *forwarded* + data is acceptable; loss of *local* data is not, and the local write is not + routed through this interface. + """ + + def write_scan(self, tags: Mapping[str, float], timestamp: datetime) -> int: + """Accept one scan's samples for forwarding. + + Args: + tags: Mapping of tag name -> value for this scan. + timestamp: The single sample time shared by every tag in the scan. + + Returns: + Number of samples accepted. May be 0 if the sink dropped them. + """ + ... + + def close(self) -> None: + """Release resources. Must be idempotent and must not raise.""" + ... + + +class NullHistorianSink: + """The default sink: accepts everything, does nothing, never fails. + + Used when remote forwarding is disabled so the write path has no branch and + no ``None`` check in the hot loop. + """ + + __slots__ = () + + def write_scan(self, tags: Mapping[str, float], timestamp: datetime) -> int: + """Discard the scan. Returns 0 — nothing was forwarded anywhere.""" + return 0 + + def close(self) -> None: + """No-op.""" + return None + + +class HistorianWriter: + """Throttled local historian write plus best-effort remote forwarding. + + Exposes :meth:`write`, which matches the + ``Callable[[Session, dict[str, float]], int]`` shape that + ``poll_runtime._poll_once`` already accepts, so wiring this in requires no + change to the control path. + + Ordering guarantee: the local write happens first and its result is what is + returned. Forwarding happens after, and any failure there is swallowed. A + broken remote historian can therefore never reduce local durability or + surface an error into the scan loop. + + Both destinations receive the *same* timestamp for a given scan, so a sample + can be correlated across the two stores exactly rather than approximately. + + The throttle is consulted exactly once per :meth:`write` call. Local and + remote are written in lockstep — a scan is either captured to both or to + neither — which keeps the two stores directly comparable and keeps the + remote volume predictable from the operator-facing capture interval. + """ + + def __init__( + self, + *, + due: Callable[[], bool], + sink: HistorianSink | None = None, + log_scan: Callable[..., int] = historian.log_scan, + clock: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + """Build a writer. + + Args: + due: Predicate consulted once per scan to decide whether to persist. + Typically ``CaptureThrottle.due``. Calling it is expected to + have the side effect of consuming the throttle window, so it is + called at most once per :meth:`write`. + sink: Forwarding destination. ``None`` means no forwarding. + log_scan: The local bulk-insert primitive. Injected for tests. + clock: Returns the aware-UTC sample time for a scan. Injected for + tests. + + Raises: + TypeError: If ``due``, ``log_scan``, or ``clock`` is not callable, + or ``sink`` is neither ``None`` nor a ``HistorianSink``. + """ + if not callable(due): + raise TypeError(f"due must be callable, got {type(due).__name__}") + if not callable(log_scan): + raise TypeError(f"log_scan must be callable, got {type(log_scan).__name__}") + if not callable(clock): + raise TypeError(f"clock must be callable, got {type(clock).__name__}") + if sink is not None and not isinstance(sink, HistorianSink): + raise TypeError( + f"sink must implement HistorianSink, got {type(sink).__name__}" + ) + + self._due = due + self._sink: HistorianSink = sink if sink is not None else NullHistorianSink() + self._log_scan = log_scan + self._clock = clock + + @property + def sink(self) -> HistorianSink: + """The configured forwarding sink (never ``None``).""" + return self._sink + + def write( + self, + session: Session, + tags: dict[str, float], + *, + signal_frame: SignalFrame | None = None, + ) -> int: + """Persist a scan locally when due, then forward it best-effort. + + Args: + session: Active session owned by the caller. Not committed here — + the caller commits historian and alarm rows together. + tags: Mapping of tag name -> value for this scan. + signal_frame: Per-scan signal-quality metadata. Forwarded verbatim + to the local write so quality is persisted with the sample. + Deliberately NOT passed to the remote sink: the sink contract is + a plain ``{tag: value}`` + timestamp forward, and widening it + would make every sink implementation depend on the quality + model. + + Returns: + Number of rows written to the **local** historian. 0 when the + throttle declined the scan. The forwarding result is deliberately + not reflected here: callers must not be able to confuse "the plant + historian is unreachable" with "nothing was recorded". + """ + if not self._due(): + return 0 + + ts = self._clock() + written = self._log_scan(session, tags, timestamp=ts, signal_frame=signal_frame) + + # Forwarding is best-effort by contract. A remote historian that is + # down, slow, or misconfigured must never propagate into the scan loop, + # so every exception stops here. Sinks are additionally expected to + # rate-limit their own logging; this guard is the last resort. + try: + self._sink.write_scan(tags, ts) + except Exception: # noqa: BLE001 - deliberate isolation boundary + logger.debug("Historian forwarding failed", exc_info=True) + + return written + + def close(self) -> None: + """Close the forwarding sink. Never raises.""" + try: + self._sink.close() + except Exception: # noqa: BLE001 - shutdown must not fail on the sink + logger.debug("Historian sink close failed", exc_info=True) diff --git a/src/p1am_control_system/backend/historian_wiring.py b/src/p1am_control_system/backend/historian_wiring.py new file mode 100644 index 0000000000..7640f83d03 --- /dev/null +++ b/src/p1am_control_system/backend/historian_wiring.py @@ -0,0 +1,87 @@ +"""Assembly of the historian write path from settings. + +One responsibility: decide, from configuration alone, what the scan loop's +historian writer should be — and make that decision testable without standing up +FastAPI, a PLC, or a database. + +Keeping this out of ``main.py`` means the wiring can be unit-tested directly; +``main`` only calls :func:`build_historian_writer` and holds the result. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +from historian_shipper import ShipperStats, StoreAndForwardSink +from historian_sink import HistorianWriter +from settings import P1AMSettings, get_settings + +__all__ = ["build_historian_writer", "shipper_stats"] + +logger = logging.getLogger("dcs_backend.historian_wiring") + +# Reported when forwarding is switched off, so the health surface always answers +# the same shape and a dashboard does not need a null branch. +_DISABLED_STATS = ShipperStats( + enabled=False, + connected=False, + queue_depth=0, + queue_max=0, + shipped_total=0, + dropped_total=0, + consecutive_failures=0, +) + + +def build_historian_writer( + due: Callable[[], bool], + settings: P1AMSettings | None = None, +) -> tuple[HistorianWriter, StoreAndForwardSink | None]: + """Build the scan-loop historian writer and, if enabled, the shipper. + + Args: + due: Throttle predicate consulted once per scan — normally + ``CaptureThrottle.due``. + settings: Configuration. Defaults to the process settings. + + Returns: + ``(writer, shipper)``. ``shipper`` is ``None`` when remote forwarding is + disabled, in which case nothing is imported, no thread is started, and + no socket is opened. + + Raises: + TypeError: If ``due`` is not callable. + """ + if not callable(due): + raise TypeError(f"due must be callable, got {type(due).__name__}") + + resolved = settings if settings is not None else get_settings() + + if not resolved.timescale_enabled: + logger.info("Remote plant historian forwarding disabled (SQLite only)") + return HistorianWriter(due=due), None + + # Imported here rather than at module scope so a bench Pi without a + # Postgres driver installed never pays for it — and never fails to boot + # because of it. + from timescale_writer import TimescaleWriter # noqa: PLC0415 + + remote = TimescaleWriter( + resolved.timescale_dsn, + connect_timeout_s=resolved.timescale_connect_timeout_s, + ) + shipper = StoreAndForwardSink( + remote, + queue_max=resolved.timescale_queue_max, + batch_size=resolved.timescale_batch_size, + flush_interval_s=resolved.timescale_flush_interval_s, + ) + shipper.start() + logger.info("Remote plant historian forwarding enabled -> %s", remote.safe_dsn) + return HistorianWriter(due=due, sink=shipper), shipper + + +def shipper_stats(shipper: StoreAndForwardSink | None) -> ShipperStats: + """Return shipper health, or a disabled snapshot when not forwarding.""" + return shipper.stats() if shipper is not None else _DISABLED_STATS diff --git a/src/p1am_control_system/backend/identity.py b/src/p1am_control_system/backend/identity.py new file mode 100644 index 0000000000..bbc85f0586 --- /dev/null +++ b/src/p1am_control_system/backend/identity.py @@ -0,0 +1,260 @@ +"""Named principals, role contracts, and short-lived opaque SCADA sessions.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +import threading +from collections.abc import Callable, Sequence +from dataclasses import InitVar, dataclass, field +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, cast, overload + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +try: + from datetime import UTC +except ImportError: # Python 3.10 support + UTC = timezone.utc # noqa: UP017 + +MINIMUM_CREDENTIAL_LENGTH = 16 +MAXIMUM_SESSION_TTL = timedelta(days=1) +DEFAULT_SESSION_TTL = timedelta(hours=8) +SESSION_TOKEN_BYTES = 32 + + +class Role(StrEnum): + """Ordered SCADA authorization roles.""" + + VIEWER = "viewer" + OPERATOR = "operator" + ENGINEER = "engineer" + ADMIN = "admin" + + +_ROLE_RANK = { + Role.VIEWER: 0, + Role.OPERATOR: 1, + Role.ENGINEER: 2, + Role.ADMIN: 3, +} + + +def _required_text(value: object, field_name: str) -> str: + """Return a stripped non-empty string or raise a contract error.""" + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + normalized = value.strip() + if not normalized: + raise ValueError(f"{field_name} must be non-empty") + return normalized + + +@dataclass(frozen=True) +class Principal: + """Authenticated named identity and its effective role.""" + + subject: str + display_name: str + role: Role + + def __post_init__(self) -> None: + object.__setattr__(self, "subject", _required_text(self.subject, "subject")) + object.__setattr__( + self, + "display_name", + _required_text(self.display_name, "display_name"), + ) + if not isinstance(self.role, Role): + raise TypeError("role must be a Role") + + def allows(self, required_role: Role) -> bool: + """Return whether this principal meets ``required_role``.""" + if not isinstance(required_role, Role): + raise TypeError("required_role must be a Role") + return _ROLE_RANK[self.role] >= _ROLE_RANK[required_role] + + +@dataclass(frozen=True) +class CredentialRecord: + """Principal paired with an API credential that is always redacted.""" + + principal: Principal + api_key: str = field(repr=False) + minimum_length: InitVar[int] = MINIMUM_CREDENTIAL_LENGTH + + def __post_init__(self, minimum_length: int) -> None: + if not isinstance(self.principal, Principal): + raise TypeError("principal must be a Principal") + if not isinstance(minimum_length, int) or minimum_length < 1: + raise ValueError("minimum_length must be a positive integer") + secret = _required_text(self.api_key, "api_key") + if len(secret) < minimum_length: + raise ValueError( + f"api_key must contain at least {minimum_length} characters" + ) + object.__setattr__(self, "api_key", secret) + + +def _parse_record(raw: object) -> CredentialRecord: + """Validate one JSON principal record.""" + if not isinstance(raw, dict): + raise TypeError("each principal configuration entry must be an object") + try: + role = Role(_required_text(raw.get("role"), "role")) + except (TypeError, ValueError) as exc: + raise ValueError("role must be viewer, operator, engineer, or admin") from exc + return CredentialRecord( + principal=Principal( + subject=_required_text(raw.get("subject"), "subject"), + display_name=_required_text(raw.get("display_name"), "display_name"), + role=role, + ), + api_key=_required_text(raw.get("api_key"), "api_key"), + ) + + +def parse_principal_config(raw_json: str) -> tuple[CredentialRecord, ...]: + """Parse the named-principal JSON contract without logging credentials.""" + text = _required_text(raw_json, "principal configuration") + try: + raw_records = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError("principal configuration must be valid JSON") from exc + if not isinstance(raw_records, list): + raise TypeError("principal configuration must be a list") + if not raw_records: + raise ValueError("principal configuration must contain at least one entry") + records = tuple(_parse_record(raw_record) for raw_record in raw_records) + subjects = [record.principal.subject for record in records] + if len(set(subjects)) != len(subjects): + raise ValueError("principal configuration contains a duplicate subject") + return records + + +class CredentialRegistry: + """Authenticate credentials against a validated immutable principal set.""" + + @overload + def __init__(self, records: Sequence[CredentialRecord]) -> None: ... + + @overload + def __init__(self, records: object) -> None: ... + + def __init__(self, records: object) -> None: + if not isinstance(records, Sequence) or isinstance(records, (str, bytes)): + raise TypeError("records must be a sequence of CredentialRecord") + normalized = tuple(records) + if not normalized: + raise ValueError("records must contain at least one credential") + if not all(isinstance(record, CredentialRecord) for record in normalized): + raise TypeError("records must contain only CredentialRecord values") + self._reject_duplicate_credentials(normalized) + self._records = cast(tuple[CredentialRecord, ...], normalized) + + @staticmethod + def _reject_duplicate_credentials(records: Sequence[CredentialRecord]) -> None: + for index, record in enumerate(records): + for candidate in records[index + 1 :]: + if hmac.compare_digest(record.api_key, candidate.api_key): + raise ValueError( + "principal configuration contains a duplicate credential" + ) + + def authenticate(self, api_key: str | None) -> Principal | None: + """Return the matching named principal without exposing credential data.""" + if not api_key or not isinstance(api_key, str): + return None + matched: Principal | None = None + for record in self._records: + if hmac.compare_digest(api_key, record.api_key): + matched = record.principal + return matched + + +@dataclass(frozen=True) +class IssuedSession: + """One newly issued opaque session token and its public metadata.""" + + token: str = field(repr=False) + principal: Principal + expires_at: datetime + + +@dataclass(frozen=True) +class _StoredSession: + principal: Principal + expires_at: datetime + + +class SessionStore: + """Thread-safe in-memory store that retains token digests, never raw tokens.""" + + def __init__( + self, + ttl: timedelta = DEFAULT_SESSION_TTL, + clock: Callable[[], datetime] | None = None, + ) -> None: + if not isinstance(ttl, timedelta): + raise TypeError("ttl must be a timedelta") + if ttl <= timedelta(0) or ttl > MAXIMUM_SESSION_TTL: + raise ValueError("ttl must be greater than zero and at most one day") + self._ttl = ttl + self._clock = clock or (lambda: datetime.now(UTC)) + self._sessions: dict[str, _StoredSession] = {} + self._lock = threading.Lock() + + @staticmethod + def _digest(token: str) -> str: + if not isinstance(token, str): + raise TypeError("token must be a string") + if not token: + raise ValueError("token must be non-empty") + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return a timezone-aware datetime") + return now + + def create(self, principal: Principal) -> IssuedSession: + """Issue one opaque session for ``principal`` within the TTL contract.""" + if not isinstance(principal, Principal): + raise TypeError("principal must be a Principal") + now = self._now() + token = secrets.token_urlsafe(SESSION_TOKEN_BYTES) + expires_at = now + self._ttl + with self._lock: + self._sessions[self._digest(token)] = _StoredSession( + principal=principal, + expires_at=expires_at, + ) + return IssuedSession(token=token, principal=principal, expires_at=expires_at) + + def resolve(self, token: str) -> Principal | None: + """Resolve a valid session and remove it if it has expired.""" + digest = self._digest(token) + with self._lock: + stored = self._sessions.get(digest) + if stored is None: + return None + if stored.expires_at <= self._now(): + del self._sessions[digest] + return None + return stored.principal + + def revoke(self, token: str) -> bool: + """Revoke ``token`` and report whether an active record existed.""" + digest = self._digest(token) + with self._lock: + return self._sessions.pop(digest, None) is not None diff --git a/src/p1am_control_system/backend/identity_config.py b/src/p1am_control_system/backend/identity_config.py new file mode 100644 index 0000000000..c3ce8b7b41 --- /dev/null +++ b/src/p1am_control_system/backend/identity_config.py @@ -0,0 +1,134 @@ +"""Environment adapter for the canonical named-identity service.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Mapping +from datetime import timedelta +from typing import cast + +from identity import ( + DEFAULT_SESSION_TTL, + CredentialRecord, + CredentialRegistry, + Principal, + Role, + SessionStore, + parse_principal_config, +) +from identity_router import IdentityService + +PRINCIPALS_VARIABLE = "P1AM_PRINCIPALS_JSON" +OPERATOR_KEY_VARIABLE = "P1AM_API_KEY" +ADMIN_KEY_VARIABLE = "P1AM_ADMIN_API_KEY" +SESSION_TTL_VARIABLE = "P1AM_SESSION_TTL_S" + + +def _session_ttl(env: Mapping[str, str]) -> timedelta: + raw = env.get(SESSION_TTL_VARIABLE) + if raw is None or not raw.strip(): + return cast(timedelta, DEFAULT_SESSION_TTL) + try: + seconds = int(raw) + except ValueError as exc: + raise ValueError(f"{SESSION_TTL_VARIABLE} must be an integer") from exc + ttl = timedelta(seconds=seconds) + try: + SessionStore(ttl=ttl) + except (TypeError, ValueError) as exc: + raise ValueError(f"{SESSION_TTL_VARIABLE} is outside the safe range") from exc + return ttl + + +def _legacy_records(env: Mapping[str, str]) -> tuple[CredentialRecord, ...]: + operator_key = env.get(OPERATOR_KEY_VARIABLE) + admin_key = env.get(ADMIN_KEY_VARIABLE) + if not operator_key and not admin_key: + return () + if operator_key and not admin_key: + return ( + _legacy_record( + "legacy.single-key", "Legacy User", Role.ADMIN, operator_key + ), + ) + if admin_key and not operator_key: + return ( + _legacy_record( + "legacy.admin", "Legacy Administrator", Role.ADMIN, admin_key + ), + ) + if operator_key == admin_key: + assert operator_key is not None + return ( + _legacy_record( + "legacy.single-key", "Legacy User", Role.ADMIN, operator_key + ), + ) + assert operator_key is not None and admin_key is not None + return ( + _legacy_record( + "legacy.operator", "Legacy Operator", Role.OPERATOR, operator_key + ), + _legacy_record("legacy.admin", "Legacy Administrator", Role.ADMIN, admin_key), + ) + + +def _legacy_record( + subject: str, + display_name: str, + role: Role, + api_key: str, +) -> CredentialRecord: + return CredentialRecord( + principal=Principal(subject=subject, display_name=display_name, role=role), + api_key=api_key, + minimum_length=1, + ) + + +def load_identity_service(env: Mapping[str, str]) -> IdentityService | None: + """Build the identity service from named JSON or compatible legacy keys.""" + if not isinstance(env, Mapping): + raise TypeError("env must be a string mapping") + named_json = env.get(PRINCIPALS_VARIABLE) + records = parse_principal_config(named_json) if named_json else _legacy_records(env) + if not records: + return None + return IdentityService( + CredentialRegistry(records), + SessionStore(ttl=_session_ttl(env)), + ) + + +_IDENTITY_VARIABLES = ( + PRINCIPALS_VARIABLE, + OPERATOR_KEY_VARIABLE, + ADMIN_KEY_VARIABLE, + SESSION_TTL_VARIABLE, +) + + +class EnvironmentIdentityProvider: + """Keep one session service while its identity configuration is unchanged.""" + + def __init__(self, environment: Callable[[], Mapping[str, str]]) -> None: + if not callable(environment): + raise TypeError("environment must be callable") + self._environment = environment + self._fingerprint: tuple[str | None, ...] | None = None + self._service: IdentityService | None = None + self._lock = threading.Lock() + + @staticmethod + def _configuration(env: Mapping[str, str]) -> tuple[str | None, ...]: + return tuple(env.get(name) for name in _IDENTITY_VARIABLES) + + def get(self) -> IdentityService | None: + """Return the stable service, rebuilding only after a configuration change.""" + env = self._environment() + fingerprint = self._configuration(env) + with self._lock: + if fingerprint != self._fingerprint: + self._service = load_identity_service(env) + self._fingerprint = fingerprint + return self._service diff --git a/src/p1am_control_system/backend/identity_router.py b/src/p1am_control_system/backend/identity_router.py new file mode 100644 index 0000000000..a980b840dc --- /dev/null +++ b/src/p1am_control_system/backend/identity_router.py @@ -0,0 +1,165 @@ +"""FastAPI session surface and reusable named-role dependencies.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Annotated, TypeAlias, cast + +from fastapi import APIRouter, Depends, HTTPException, Response, Security, status +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer +from identity import CredentialRegistry, IssuedSession, Principal, Role, SessionStore +from pydantic import BaseModel, ConfigDict + +API_KEY_HEADER_NAME = "X-API-" + "Key" # pragma: allowlist secret +_api_key_header = APIKeyHeader(name=API_KEY_HEADER_NAME, auto_error=False) +_bearer = HTTPBearer(auto_error=False) + +ApiKey = Annotated[str | None, Security(_api_key_header)] +BearerCredential = Annotated[ + HTTPAuthorizationCredentials | None, + Security(_bearer), +] +IdentityServiceProvider = Callable[[], "IdentityService | None"] +IdentityServiceSource: TypeAlias = "IdentityService | IdentityServiceProvider" + + +class PrincipalResponse(BaseModel): + """Public identity metadata returned to an authenticated client.""" + + model_config = ConfigDict(from_attributes=True) + + subject: str + display_name: str + role: Role + + +class SessionResponse(BaseModel): + """New opaque session and its expiry/identity metadata.""" + + token: str + expires_at: datetime + principal: PrincipalResponse + + +class IdentityService: + """Coordinate credential authentication and opaque session lifecycle.""" + + def __init__( + self, + registry: CredentialRegistry, + sessions: SessionStore, + ) -> None: + if not isinstance(registry, CredentialRegistry): + raise TypeError("registry must be a CredentialRegistry") + if not isinstance(sessions, SessionStore): + raise TypeError("sessions must be a SessionStore") + self._registry = registry + self._sessions = sessions + + def login(self, api_key: str | None) -> IssuedSession | None: + """Authenticate one credential and issue a session on success.""" + principal = self._registry.authenticate(api_key) + return self._sessions.create(principal) if principal is not None else None + + def resolve( + self, + api_key: str | None, + bearer: HTTPAuthorizationCredentials | None, + ) -> Principal | None: + """Resolve either a named API key or a short-lived bearer session.""" + if bearer is not None and bearer.scheme.lower() == "bearer": + return self._sessions.resolve(bearer.credentials) + return self._registry.authenticate(api_key) + + def revoke(self, bearer: HTTPAuthorizationCredentials | None) -> bool: + """Revoke a bearer session when it is present and validly shaped.""" + if bearer is None or bearer.scheme.lower() != "bearer": + return False + return cast(bool, self._sessions.revoke(bearer.credentials)) + + +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid credential.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _configured_service(source: IdentityServiceSource) -> IdentityService: + service = source() if callable(source) else source + if service is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Server identity service is not configured.", + ) + if not isinstance(service, IdentityService): + raise TypeError("identity service provider returned an invalid value") + return service + + +def require_role( + service: IdentityServiceSource, + required_role: Role, +) -> Callable[..., Principal]: + """Build a dependency enforcing a named principal and minimum role.""" + if not isinstance(service, IdentityService) and not callable(service): + raise TypeError("service must be an IdentityService or provider") + if not isinstance(required_role, Role): + raise TypeError("required_role must be a Role") + + def dependency( + api_key: ApiKey = None, bearer: BearerCredential = None + ) -> Principal: + principal = _configured_service(service).resolve(api_key, bearer) + if principal is None: + raise _unauthorized() + if not principal.allows(required_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"This operation requires the {required_role.value} role.", + ) + return principal + + return dependency + + +def _session_response(issued: IssuedSession) -> SessionResponse: + return SessionResponse( + token=issued.token, + expires_at=issued.expires_at, + principal=PrincipalResponse.model_validate(issued.principal), + ) + + +def create_identity_router(service: IdentityServiceSource) -> APIRouter: + """Create the named-session API router for one identity service.""" + if not isinstance(service, IdentityService) and not callable(service): + raise TypeError("service must be an IdentityService or provider") + router = APIRouter(prefix="/api/auth", tags=["identity"]) + authenticated = require_role(service, Role.VIEWER) + + @router.post("/session", status_code=status.HTTP_201_CREATED) + async def create_session(api_key: ApiKey = None) -> SessionResponse: + issued = _configured_service(service).login(api_key) + if issued is None: + raise _unauthorized() + return _session_response(issued) + + @router.get("/me") + async def get_principal( + principal: Principal = Depends(authenticated), # noqa: B008 + ) -> PrincipalResponse: + # Annotated local: see the typing convention note in SPEC.md — CI runs + # mypy from the repo root, where flat intra-package imports become Any. + response: PrincipalResponse = PrincipalResponse.model_validate(principal) + return response + + @router.delete("/session", status_code=status.HTTP_204_NO_CONTENT) + async def delete_session(bearer: BearerCredential = None) -> Response: + if not _configured_service(service).revoke(bearer): + raise _unauthorized() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + return router diff --git a/src/p1am_control_system/backend/main.py b/src/p1am_control_system/backend/main.py index 3ef53f2272..694ce5f371 100644 --- a/src/p1am_control_system/backend/main.py +++ b/src/p1am_control_system/backend/main.py @@ -1,10 +1,11 @@ import asyncio import logging import os +import shutil import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone try: from datetime import UTC @@ -12,15 +13,31 @@ UTC = timezone.utc # noqa: UP017 from typing import Any, cast -import historian +from advisory_router import create_advisory_router +from alarm_router import create_alarm_router +from alarm_service import AlarmService, manager_from_routing from alicat_manager import AlicatManager, AlicatMFC +from asset_health import ( + AssetHealthPolicy, + AssetHealthReport, + AssetHealthService, + AssetObservation, +) +from audit_middleware import MutationAuditMiddleware +from audit_router import create_audit_router from auth_config import ( CREDENTIAL_HEADER_NAME, + identity_service, require_admin_key, require_api_key, + require_engineer_key, + resolve_optional_principal, verify_operator_key, ) -from config_store import load_config, load_model, save_config, save_model +from config_store import load_config, load_model, save_config +from configuration_repository import SqliteRevisionRepository +from configuration_router import create_configuration_router +from configuration_workflow import ConfigurationWorkflow from cors_config import resolve_cors_settings from data_capture import ( TRENDS_MAX_POINTS, @@ -43,6 +60,7 @@ File, HTTPException, Query, + Request, Security, UploadFile, WebSocket, @@ -51,6 +69,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.security import APIKeyHeader +from historian_wiring import build_historian_writer, shipper_stats +from identity import Principal +from identity_router import create_identity_router from models import ( AlicatGasPayload, AlicatMFCState, @@ -65,19 +86,32 @@ TagLog, ) from mpc import simulate_pid_vs_mpc +from operations_router import create_operations_router +from operator_router import create_operator_router from performance import PerformanceConfig, PerformanceController, PerformanceMode from pid_tuning import identify_fopdt_and_tune from plant_model import TagDefinition from plc_factory import PLCFactory from poll_runtime import _connect_once, _poll_once from power_supply_integration import PowerSupplyService, create_power_supply_router +from product_router import create_product_router from project_import import import_project_archive +from protection_management import ProtectionService, representative_protections from pydantic import BaseModel from pydantic import Field as PydanticField +from recovery_package import RecoveryPackageService +from representative_product import build_representative_product +from saved_investigation import InvestigationService, SqliteInvestigationRepository +from scenario_router import create_scenario_router from settings import get_settings +from shift_log import ShiftLogService +from shift_log_repository import SqliteShiftLogRepository +from signal_quality import SignalFrame from simulator_client import SimulatedPLCClient from sqlmodel import Session, col, select from state import SystemState +from system_health import SystemHealthService +from system_router import create_system_router from temperature_integration import ( TemperatureService, create_temperature_router, @@ -168,9 +202,37 @@ def _persist_setting(key: str, payload: dict[str, object]) -> None: ) -def _throttled_log_scan(session: Session, tags: dict[str, float]) -> int: - """Persist a scan to the historian only when the throttle says it's due.""" - return historian.log_scan(session, tags) if capture_throttle.due() else 0 +# The scan-loop historian writer. Persists locally when the capture throttle +# allows, then forwards the same scan to the remote plant historian if one is +# configured. `historian_shipper` is None unless forwarding is enabled, in which +# case no thread and no driver import happen at all. +historian_writer, historian_shipper = build_historian_writer( + capture_throttle.due, settings +) + + +def _throttled_log_scan( + session: Session, + tags: dict[str, float], + *, + signal_frame: SignalFrame | None = None, +) -> int: + """Persist a scan to the historian only when the throttle says it's due. + + Thin wrapper kept so the poll loop's injected callable has a stable name and + signature; the throttle, local write, and remote forward all live in + :class:`historian_sink.HistorianWriter`. + + ``signal_frame`` carries the per-scan quality metadata that + ``poll_runtime.ScanLogger`` requires (PR #4091) and is forwarded verbatim to + the local write, so quality survives consolidation with the historian + forwarding path (PR #4065). + """ + # Annotated local: the backend uses flat intra-package imports, which mypy + # resolves to Any when it runs from the repo root rather than this + # directory. Pinning the type here keeps the check honest either way. + rows: int = historian_writer.write(session, tags, signal_frame=signal_frame) + return rows class ConnectionManager: @@ -296,6 +358,107 @@ def build_alarm_engine(config: RoutingConfig) -> Any: control_context = SystemState(alarm_engine_factory=build_alarm_engine) control_context.attach_clients(plc_client, backup_simulator) +professional_alarm_service = AlarmService( + manager_from_routing(control_context.active_config) +) +protection_service = ProtectionService( + representative_protections(), now=lambda: datetime.now(UTC) +) +representative_product = build_representative_product(lambda: datetime.now(UTC)) + + +def _apply_control_config(config: RoutingConfig) -> None: + """Synchronize proven controls and the supervisory alarm workspace.""" + alarm_manager = manager_from_routing(config) + control_context.apply_config(config, plc_client, backup_simulator) + professional_alarm_service.reconfigure(alarm_manager) + + +async def _deploy_approved_routing(config: RoutingConfig) -> None: + """Deploy one approved revision before publishing it to runtime readers.""" + if not isinstance(config, RoutingConfig): + raise TypeError("config must be a RoutingConfig") + if plc_client.connected: + if not await plc_client.write_routing(config): + raise RuntimeError("PLC rejected the approved configuration") + if not await plc_client.save_to_flash(): + raise RuntimeError("PLC configuration was not saved to flash") + if not await backup_simulator.write_routing(config): + raise RuntimeError("simulator rejected the approved configuration") + if not await backup_simulator.save_to_flash(): + raise RuntimeError("simulator configuration was not saved") + _apply_control_config(config) + global _persisted_routing + _persisted_routing = config + + +configuration_workflow = ConfigurationWorkflow( + SqliteRevisionRepository(_config_session), + _deploy_approved_routing, +) +investigation_service = InvestigationService( + SqliteInvestigationRepository(_config_session) +) +shift_log_service = ShiftLogService(SqliteShiftLogRepository(_config_session)) +asset_health_service = AssetHealthService( + AssetHealthPolicy(), now=lambda: datetime.now(UTC) +) + + +def _representative_asset_health() -> AssetHealthReport: + """Return invented maintenance context; no field identity or value is used.""" + now = datetime.now(UTC) + observations = ( + AssetObservation( + observed_at=now - timedelta(minutes=10), + value=15.0, + reference=10.0, + command=True, + feedback=False, + running=True, + ), + AssetObservation( + observed_at=now, + value=15.0, + reference=10.0, + command=True, + feedback=False, + running=True, + ), + ) + return asset_health_service.assess( + "SYNTHETIC.FEED.PUMP", + observations, + calibration_due_at=now - timedelta(days=1), + ) + + +software_revision = os.environ.get("P1AM_SOFTWARE_REVISION", "development-unidentified") +recovery_service = RecoveryPackageService( + configuration_workflow, + software_revision=software_revision, +) +system_health_service = SystemHealthService( + workflow=configuration_workflow, + recovery=recovery_service, + engine=engine, + software_revision=software_revision, + plc_connected=lambda: plc_client.connected, + simulator_available=lambda: True, + clock_synchronized=lambda: None, + storage_free_bytes=lambda: shutil.disk_usage(".").free, + service_running=lambda: not shutdown_event.is_set(), + driver_identity=lambda: ( + f"{plc_client.__class__.__module__}.{plc_client.__class__.__name__}" + ), +) + + +def _acceptance_identity() -> tuple[str, str]: + identity = system_health_service.identity() + if identity.configuration_sha256 is None: + raise ValueError("an identified active configuration is required") + return identity.software_revision, identity.configuration_revision async def modbus_connect_background() -> None: @@ -324,7 +487,7 @@ def _publish_active_config(config: RoutingConfig) -> None: """ if _persisted_routing is not None: config = config.model_copy(update={"interlocks": _persisted_routing.interlocks}) - control_context.apply_config(config, plc_client, backup_simulator) + _apply_control_config(config) def require_read_auth( @@ -392,6 +555,9 @@ async def poll_plc_loop() -> None: # the reference is atomic, so a concurrent reader sees a whole frame. if frame: latest_frame = frame + quality = frame.get("comms_health", {}).get("quality") + if quality in {"good", "uncertain", "simulated"}: + professional_alarm_service.observe(frame.get("tags_dict", {})) consecutive_failures = 0 except Exception as loop_err: consecutive_failures += 1 @@ -443,11 +609,16 @@ def _restore_persisted_settings(session: Session) -> None: """ global _persisted_routing try: - routing = load_model(session, "routing", RoutingConfig) + active_revision = configuration_workflow.active() + routing = ( + active_revision.payload + if active_revision is not None + else load_model(session, "routing", RoutingConfig) + ) if routing is not None: _persisted_routing = routing - control_context.apply_config(routing, plc_client, backup_simulator) - logger.info("Recalled persisted routing (alarm setpoints + PID).") + _apply_control_config(routing) + logger.info("Recalled de-energized configuration settings.") except Exception as exc: # noqa: BLE001 - never block boot on a bad blob logger.warning("Routing recall skipped: %s", exc) try: @@ -499,6 +670,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await retention_task await alicat_manager.stop() await plc_client.disconnect() + # Flush the forward queue last, and with a bound. An unreachable historian + # must not be able to hold the controller in shutdown — the local SQLite + # copy is already durable, so anything still queued is expendable. + if historian_shipper is not None: + await asyncio.to_thread( + historian_shipper.close, timeout_s=settings.timescale_shutdown_flush_s + ) app = FastAPI( @@ -507,6 +685,65 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: lifespan=lifespan, ) app.state.control_context = control_context +app.include_router(create_identity_router(identity_service)) +app.include_router(create_audit_router(get_session, require_engineer_key)) +app.include_router( + create_alarm_router( + professional_alarm_service, + operator_dependency=require_api_key, + engineer_dependency=require_engineer_key, + ) +) +app.include_router( + create_operator_router( + protection_service, + engineer_dependency=require_engineer_key, + ) +) +app.include_router( + create_operations_router( + investigation_service, + shift_log_service, + asset_report_provider=_representative_asset_health, + operator_dependency=require_api_key, + ) +) +app.include_router( + create_product_router( + representative_product.procedure, + representative_product.connectors, + representative_product.notifications, + representative_product.availability, + operator_dependency=require_api_key, + ) +) +app.include_router( + create_advisory_router( + representative_product.advisories, + operator_dependency=require_api_key, + ) +) +app.include_router( + create_configuration_router( + configuration_workflow, + engineer_dependency=require_engineer_key, + admin_dependency=require_admin_key, + ) +) +app.include_router( + create_system_router( + recovery_service, + system_health_service, + engineer_dependency=require_engineer_key, + admin_dependency=require_admin_key, + ) +) +app.include_router( + create_scenario_router( + identity_provider=_acceptance_identity, + admin_dependency=require_admin_key, + ) +) app.include_router(create_power_supply_router(power_supply_service)) app.include_router(create_temperature_router(temperature_service)) @@ -536,6 +773,28 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) +def _audit_principal(request: Request) -> Principal | None: + return resolve_optional_principal( + request.headers.get(CREDENTIAL_HEADER_NAME), + request.headers.get("Authorization"), + ) + + +def _configuration_revision() -> str: + active = configuration_workflow.active() + if active is not None and active.activation_identity: + return cast(str, active.activation_identity) + return os.environ.get("P1AM_CONFIG_REVISION", "unversioned") + + +app.add_middleware( + MutationAuditMiddleware, + engine=engine, + principal_resolver=_audit_principal, + configuration_revision=_configuration_revision, +) + + @app.get("/", response_class=HTMLResponse) async def root_info() -> str: """HTML landing page directing users to HMI dashboard or API documentation.""" @@ -636,57 +895,15 @@ async def get_routing() -> RoutingConfig: @app.post("/api/routing", dependencies=[Depends(require_admin_key)]) async def update_routing(config: RoutingConfig) -> dict[str, str]: - """Write new routing configurations to the PLC. - - Args: - config: RoutingConfig model. - - Returns: - JSON response indicating success. - """ - control_context.apply_config(config, plc_client, backup_simulator) - - # Persist the SCADA-authoritative routing (interlocks/alarm setpoints + PID) - # so it survives a restart independent of PLC flash, and refresh the overlay. - global _persisted_routing - _persisted_routing = config - try: - with _config_session() as s: - save_model(s, "routing", config) - except Exception as exc: # noqa: BLE001 - persistence must not fail a deploy - logger.warning("Persisting routing failed (non-fatal): %s", exc) - - if not plc_client.connected: - await backup_simulator.write_routing(config) - return { - "status": "success", - "message": "Configuration successfully applied to simulated PLC.", - } - - success = await plc_client.write_routing(config) - await backup_simulator.write_routing(config) - - if not success: - raise HTTPException( - status_code=500, - detail="Failed to write routing parameters to PLC registers.", - ) - - save_success = await plc_client.save_to_flash() - await backup_simulator.save_to_flash() - - if not save_success: - raise HTTPException( - status_code=500, - detail=( - "Config registers written, but failed to trigger 'Save to Flash' coil." - ), - ) - - return { - "status": "success", - "message": ("Configuration successfully deployed and saved to PLC NVRAM."), - } + """Reject the retired direct-activation path without applying the payload.""" + del config + raise HTTPException( + status_code=409, + detail=( + "Direct configuration activation is disabled; use the protected " + "draft, validation, review, approval, and activation workflow." + ), + ) # NOTE: E-stop *activation* is intentionally left unauthenticated so a panic @@ -858,6 +1075,37 @@ def get_events( return list(results) +def _trend_signal_metadata( + db: Session, + tag_name: str, + sample_times: list[datetime], +) -> dict[str, list[Any]]: + if not sample_times: + return { + "qualities": [], + "diagnostic_reasons": [], + "source_timestamps": [], + "sequences": [], + "sources": [], + } + rows = db.exec( + select(TagLog) + .where(col(TagLog.tag_name) == tag_name) + .where(col(TagLog.timestamp).in_(sample_times)) + ).all() + by_timestamp = {row.timestamp: row for row in rows} + ordered = [by_timestamp[timestamp] for timestamp in sample_times] + return { + "qualities": [row.quality for row in ordered], + "diagnostic_reasons": [row.diagnostic_reason for row in ordered], + "source_timestamps": [ + (row.source_timestamp or row.timestamp).isoformat() for row in ordered + ], + "sequences": [row.sequence for row in ordered], + "sources": [row.source for row in ordered], + } + + @app.get("/api/trends", dependencies=[Depends(require_read_auth)]) def get_trends( tag_id: str, @@ -908,7 +1156,12 @@ def get_trends( elif smoothing == "exponential_smoothing" and values: values = exponential_smoothing(values, alpha) - return {"timestamps": timestamps, "values": values, "truncated": truncated} + return { + "timestamps": timestamps, + "values": values, + **_trend_signal_metadata(db, tag_name, sample_times), + "truncated": truncated, + } @app.get("/api/export", dependencies=[Depends(require_read_auth)]) @@ -966,6 +1219,21 @@ def get_capture_status( return capture_stats(db, capturing=True) +@app.get("/api/historian/shipper", dependencies=[Depends(require_read_auth)]) +async def get_historian_shipper_status() -> dict[str, object]: + """Report remote plant-historian forwarding health. + + Engineering diagnostic, not an operator alarm. A forwarding outage is not an + operator action and deliberately does not reach the alarm banner. + + This exists so a flat line in a plant dashboard can be told apart from a + flat process value. ``lag_s`` climbing while the process is running means + the trend has a hole in it, not that the plant was idle. + """ + stats: dict[str, object] = shipper_stats(historian_shipper).as_dict() + return stats + + @app.get("/api/capture/config", response_model=CaptureConfig) async def get_capture_config() -> CaptureConfig: """Return the current historian sampling interval (seconds between writes).""" diff --git a/src/p1am_control_system/backend/models.py b/src/p1am_control_system/backend/models.py index fbdfd87bec..b29babb934 100644 --- a/src/p1am_control_system/backend/models.py +++ b/src/p1am_control_system/backend/models.py @@ -50,10 +50,15 @@ class TagLog(SQLModel, table=True): # type: ignore[call-arg] id: int | None = Field(default=None, primary_key=True) tag_name: str value: float + source_timestamp: datetime | None = Field(default_factory=utc_now) timestamp: datetime = Field( default_factory=utc_now, index=True, ) + quality: str = Field(default="uncertain", index=True) + diagnostic_reason: str | None = Field(default="legacy_unqualified") + sequence: int = Field(default=0, index=True) + source: str = Field(default="legacy.adapter", index=True) class PlantArea(SQLModel, table=True): # type: ignore[call-arg] diff --git a/src/p1am_control_system/backend/notification_policy.py b/src/p1am_control_system/backend/notification_policy.py new file mode 100644 index 0000000000..1c678a2e31 --- /dev/null +++ b/src/p1am_control_system/backend/notification_policy.py @@ -0,0 +1,178 @@ +"""Deterministic alarm notification, escalation, rate-limit, and audit policy.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_SECRET = re.compile( + r"(?i)\b(password|secret|token|api[_-]?key|credential)\s*[:=]\s*\S+" +) + + +def _redact(message: str) -> str: + return _SECRET.sub(lambda match: f"{match.group(1)}=[REDACTED]", message) + + +class AlarmNotice(BaseModel): + model_config = ConfigDict(frozen=True) + + alarm_id: str + priority: Literal["high", "critical"] + occurred_at: datetime + message: str = Field(min_length=1, max_length=1000) + + @field_validator("alarm_id") + @classmethod + def _synthetic_alarm(cls, value: str) -> str: + if not value.startswith("SYNTHETIC."): + raise ValueError("alarm_id must begin with SYNTHETIC.") + return value + + +class NotificationPolicy(BaseModel): + model_config = ConfigDict(frozen=True) + + initial_delay: timedelta + escalation_delay: timedelta + primary_recipient: str = Field(min_length=1) + escalation_recipient: str = Field(min_length=1) + suppressed_alarm_ids: frozenset[str] = frozenset() + max_deliveries: int = Field(default=20, gt=0) + rate_limit_window: timedelta = timedelta(minutes=5) + + @model_validator(mode="after") + def _valid_delays(self) -> NotificationPolicy: + if self.initial_delay < timedelta(0): + raise ValueError("initial_delay must be nonnegative") + if self.escalation_delay < self.initial_delay: + raise ValueError("escalation_delay cannot precede initial_delay") + if self.rate_limit_window <= timedelta(0): + raise ValueError("rate_limit_window must be positive") + return self + + +class NotificationChannel(Protocol): + def send(self, recipient: str, message: str) -> None: ... + + +class NotificationAudit(BaseModel): + model_config = ConfigDict(frozen=True) + + alarm_id: str + recipient: str | None + stage: Literal["primary", "escalation", "policy", "acknowledgment"] + outcome: Literal["delivered", "suppressed", "cancelled", "rate_limited"] + occurred_at: datetime + message: str + actor: str | None = None + + +class NotificationService: + def __init__( + self, + policy: NotificationPolicy, + channel: NotificationChannel, + now: Callable[[], datetime], + ) -> None: + self._policy = policy + self._channel = channel + self._now = now + self._active: dict[str, AlarmNotice] = {} + self._completed_stages: set[tuple[str, str]] = set() + self._delivery_times: list[datetime] = [] + self._audit: list[NotificationAudit] = [] + + @property + def policy(self) -> NotificationPolicy: + return self._policy + + @staticmethod + def _message(notice: AlarmNotice) -> str: + return _redact(f"{notice.alarm_id}: {notice.message}") + + def raise_alarm(self, notice: AlarmNotice) -> None: + if notice.alarm_id in self._active: + raise ValueError("alarm is already active") + if notice.alarm_id in self._policy.suppressed_alarm_ids: + self._audit.append( + NotificationAudit( + alarm_id=notice.alarm_id, + recipient=None, + stage="policy", + outcome="suppressed", + occurred_at=self._now(), + message=self._message(notice), + ) + ) + return + self._active[notice.alarm_id] = notice + + def acknowledge(self, alarm_id: str, actor: str) -> None: + try: + notice = self._active.pop(alarm_id) + except KeyError as exc: + raise KeyError(f"unknown active alarm: {alarm_id}") from exc + self._audit.append( + NotificationAudit( + alarm_id=alarm_id, + recipient=None, + stage="acknowledgment", + outcome="cancelled", + occurred_at=self._now(), + message=self._message(notice), + actor=actor, + ) + ) + + def _rate_limited(self, now: datetime) -> bool: + cutoff = now - self._policy.rate_limit_window + self._delivery_times = [ + value for value in self._delivery_times if value > cutoff + ] + return len(self._delivery_times) >= self._policy.max_deliveries + + def tick(self) -> list[NotificationAudit]: + now = self._now() + delivered: list[NotificationAudit] = [] + stages: tuple[tuple[Literal["primary", "escalation"], timedelta, str], ...] = ( + ("primary", self._policy.initial_delay, self._policy.primary_recipient), + ( + "escalation", + self._policy.escalation_delay, + self._policy.escalation_recipient, + ), + ) + for notice in self._active.values(): + for stage, delay, recipient in stages: + key = (notice.alarm_id, stage) + if key in self._completed_stages or now - notice.occurred_at < delay: + continue + outcome: Literal["delivered", "rate_limited"] + message = self._message(notice) + if self._rate_limited(now): + outcome = "rate_limited" + else: + self._channel.send(recipient, message) + self._delivery_times.append(now) + outcome = "delivered" + audit = NotificationAudit( + alarm_id=notice.alarm_id, + recipient=recipient, + stage=stage, + outcome=outcome, + occurred_at=now, + message=message, + ) + self._audit.append(audit) + self._completed_stages.add(key) + if outcome == "delivered": + delivered.append(audit) + return delivered + + def audit(self) -> list[NotificationAudit]: + return list(self._audit) diff --git a/src/p1am_control_system/backend/operations_router.py b/src/p1am_control_system/backend/operations_router.py new file mode 100644 index 0000000000..0cef62150b --- /dev/null +++ b/src/p1am_control_system/backend/operations_router.py @@ -0,0 +1,132 @@ +"""REST adapter for investigations, asset advisories, and shift handover.""" + +from __future__ import annotations + +import io +from collections.abc import Callable + +from asset_health import AssetHealthReport +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from identity import Principal +from pydantic import BaseModel, ConfigDict, Field +from saved_investigation import ( + InvestigationService, + InvestigationSpec, + SavedInvestigation, +) +from shift_log import ( + HandoverAcknowledgment, + ShiftEntry, + ShiftEntryDraft, + ShiftLogService, + ShiftSignoff, +) + + +class HandoverBody(BaseModel): + model_config = ConfigDict(frozen=True) + + note: str = Field(min_length=1, max_length=1000) + + +def _domain_call(operation: Callable[[], object]) -> object: + try: + return operation() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +def create_operations_router( + investigations: InvestigationService, + shifts: ShiftLogService, + asset_report_provider: Callable[[], AssetHealthReport], + operator_dependency: Callable[..., Principal], +) -> APIRouter: + if not isinstance(investigations, InvestigationService): + raise TypeError("investigations must be an InvestigationService") + if not isinstance(shifts, ShiftLogService): + raise TypeError("shifts must be a ShiftLogService") + if not callable(asset_report_provider) or not callable(operator_dependency): + raise TypeError("operations providers and dependencies must be callable") + router = APIRouter(prefix="/api/operator", tags=["operator-operations"]) + + @router.post("/investigations") + async def save_investigation( + spec: InvestigationSpec, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> SavedInvestigation: + result = _domain_call(lambda: investigations.save(spec, principal)) + assert isinstance(result, SavedInvestigation) + return result + + @router.get("/investigations/{investigation_id}") + async def get_investigation(investigation_id: str) -> SavedInvestigation: + result = _domain_call(lambda: investigations.get(investigation_id)) + assert isinstance(result, SavedInvestigation) + return result + + @router.get("/investigations/{investigation_id}/export") + async def export_investigation(investigation_id: str) -> StreamingResponse: + try: + artifact = investigations.export(investigation_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return StreamingResponse( + io.BytesIO(artifact.payload), + media_type="application/zip", + headers={ + "Content-Disposition": ( + f'attachment; filename="{investigation_id}-investigation.zip"' + ), + "X-Artifact-SHA256": artifact.sha256, + "X-Investigation-ID": investigation_id, + }, + ) + + @router.get("/assets/health/representative") + async def representative_asset_health() -> AssetHealthReport: + return asset_report_provider() + + @router.post("/shift-log") + async def append_shift_entry( + draft: ShiftEntryDraft, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> ShiftEntry: + result = _domain_call(lambda: shifts.append(draft, principal)) + assert isinstance(result, ShiftEntry) + return result + + @router.get("/shift-log") + async def search_shift_entries( + query: str = Query(default="", max_length=200), + ) -> list[ShiftEntry]: + entries: list[ShiftEntry] = shifts.search(query) + return entries + + @router.post("/shift-log/{entry_id}/signoff") + async def sign_off_shift_entry( + entry_id: str, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> ShiftSignoff: + result = _domain_call(lambda: shifts.sign_off(entry_id, principal)) + assert isinstance(result, ShiftSignoff) + return result + + @router.post("/shift-log/{entry_id}/handover") + async def acknowledge_handover( + entry_id: str, + body: HandoverBody, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> HandoverAcknowledgment: + result = _domain_call( + lambda: shifts.acknowledge_handover(entry_id, principal, body.note) + ) + assert isinstance(result, HandoverAcknowledgment) + return result + + return router diff --git a/src/p1am_control_system/backend/operator_router.py b/src/p1am_control_system/backend/operator_router.py new file mode 100644 index 0000000000..e1a7b4d93d --- /dev/null +++ b/src/p1am_control_system/backend/operator_router.py @@ -0,0 +1,105 @@ +"""REST adapter for the non-confidential synthetic operator workspace.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from identity import Principal +from process_overview import ProcessOverview, synthetic_process_overview +from protection_management import ( + BypassRequest, + ManagedBypass, + ProtectionDefinition, + ProtectionService, + TripRecord, +) +from pydantic import BaseModel, ConfigDict, Field + + +class TripRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + group_id: str = Field(min_length=1, max_length=100) + + +class BypassBody(BaseModel): + model_config = ConfigDict(frozen=True) + + reason: str = Field(min_length=8, max_length=500) + expires_at: datetime + + +class ProtectionSnapshot(BaseModel): + model_config = ConfigDict(frozen=True) + + definitions: list[ProtectionDefinition] + trips: list[TripRecord] + active_bypasses: list[ManagedBypass] + + +def _translate_domain( + operation: Callable[[], TripRecord | ManagedBypass], +) -> TripRecord | ManagedBypass: + try: + return operation() + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +def create_operator_router( + protections: ProtectionService, + engineer_dependency: Callable[..., Principal], +) -> APIRouter: + """Build the bounded representative operator API.""" + if not isinstance(protections, ProtectionService): + raise TypeError("protections must be a ProtectionService") + if not callable(engineer_dependency): + raise TypeError("engineer_dependency must be callable") + router = APIRouter(prefix="/api/operator", tags=["operator"]) + + @router.get("/overview") + async def overview() -> ProcessOverview: + return synthetic_process_overview() + + @router.get("/protections") + async def protection_snapshot() -> ProtectionSnapshot: + return ProtectionSnapshot( + definitions=protections.definitions(), + trips=protections.trips(), + active_bypasses=protections.active_bypasses(), + ) + + @router.post("/protections/{protection_id}/trips") + async def trip( + protection_id: str, + request: TripRequest, + _principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> TripRecord: + return _translate_domain( + lambda: protections.trip(protection_id, group_id=request.group_id) + ) + + @router.post("/protections/{protection_id}/bypasses") + async def bypass( + protection_id: str, + request: BypassBody, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + ) -> ManagedBypass: + return _translate_domain( + lambda: protections.request_bypass( + BypassRequest( + protection_id=protection_id, + reason=request.reason, + expires_at=request.expires_at, + ), + principal, + ) + ) + + return router diff --git a/src/p1am_control_system/backend/poll_runtime.py b/src/p1am_control_system/backend/poll_runtime.py index 902ddbca40..70b7988bfd 100644 --- a/src/p1am_control_system/backend/poll_runtime.py +++ b/src/p1am_control_system/backend/poll_runtime.py @@ -9,15 +9,48 @@ import logging from collections.abc import Callable, Iterator -from typing import Any +from typing import Any, Protocol import historian from alarm_processing import process_alarm_events from models import RoutingConfig from power_supply_passthrough import ensure_power_supply_passthrough +from signal_quality import SignalFrame, SignalFrameFactory, SignalQuality from sqlmodel import Session logger = logging.getLogger("dcs_backend.poll_runtime") +_default_signal_frames = SignalFrameFactory() + + +class ScanLogger(Protocol): + """Historian write seam that preserves qualified signal metadata.""" + + def __call__( + self, + session: Session, + tags: dict[str, float], + *, + signal_frame: SignalFrame | None = None, + ) -> int: ... + + +def _health_payload(frame: SignalFrame | None) -> dict[str, object]: + if frame is None: + return { + "quality": SignalQuality.BAD.value, + "diagnostic_reason": "no_data", + "sequence": None, + "server_timestamp": None, + "source": "unavailable", + } + sample = next(iter(frame.samples.values())) + return { + "quality": sample.quality.value, + "diagnostic_reason": sample.diagnostic_reason, + "sequence": frame.sequence, + "server_timestamp": frame.server_timestamp.isoformat(), + "source": sample.source, + } def _reengage_service_estop(service: Any) -> None: @@ -115,11 +148,12 @@ async def _poll_once( active_alarm_map: dict[str, dict[str, Any]], session_factory: Callable[[], Iterator[Session]], estop_active: bool, - log_scan: Callable[[Session, dict[str, float]], int] = historian.log_scan, + log_scan: ScanLogger = historian.log_scan, process_events: Callable[ [Any, dict[str, float], dict[str, dict[str, Any]]], list[Any], ] = process_alarm_events, + signal_frames: SignalFrameFactory | None = None, ) -> dict[str, Any]: """Run one PLC scan, broadcast it, and persist historian/alarm rows.""" if not isinstance(latest_tag_values, dict): @@ -131,6 +165,8 @@ async def _poll_once( f"active_alarm_map must be a dict, got {type(active_alarm_map).__name__}" ) + frame_factory = signal_frames or _default_signal_frames + frame: SignalFrame | None = None tags = None if plc.connected: tags = await plc.read_tags() @@ -145,11 +181,20 @@ async def _poll_once( # and by the connection dropping (which routes to the sim below). if latest_tag_values: tags = dict(latest_tag_values) + frame = frame_factory.stale( + tags, + source="plc.driver", + reason="read_timeout", + ) + else: + frame = frame_factory.good(tags, source="plc.driver") if tags is None and not plc.connected: # No live PLC (offline / dev, or the connection has dropped) — the # simulator drives the plant so the HMI still animates. On real hardware # the background connect loop is reconnecting in parallel. tags = await backup.read_tags() + if tags is not None: + frame = frame_factory.simulated(tags, source="synthetic.simulator") if tags is not None and not isinstance(tags, dict): raise TypeError(f"poll tags must be a dict or None, got {type(tags).__name__}") @@ -188,6 +233,8 @@ async def _poll_once( payload = { "tags": tag_list, "tags_dict": tags if tags is not None else {}, + "tag_samples": frame.to_payload() if frame is not None else {}, + "comms_health": _health_payload(frame), "alicats": alicats.get_devices_data(), "active_alarms": active_alarm_map, "e_stop_active": estop_active, @@ -201,9 +248,10 @@ async def _poll_once( db_session = None try: db_session = next(session_factory()) - log_scan(db_session, tags) - for event_log in process_events(alarm_engine, tags, active_alarm_map): - db_session.add(event_log) + log_scan(db_session, tags, signal_frame=frame) + if frame is not None and frame.alarm_eligible: + for event_log in process_events(alarm_engine, tags, active_alarm_map): + db_session.add(event_log) db_session.commit() except Exception as db_err: if db_session: diff --git a/src/p1am_control_system/backend/process_overview.py b/src/p1am_control_system/backend/process_overview.py new file mode 100644 index 0000000000..b2c7bbced2 --- /dev/null +++ b/src/p1am_control_system/backend/process_overview.py @@ -0,0 +1,151 @@ +"""Synthetic multi-area process and reusable high-performance faceplate contracts.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + +SignalQuality = Literal["good", "uncertain", "bad", "stale", "simulated"] +OperatingMode = Literal["off", "manual", "automatic", "unavailable"] +AlarmState = Literal["normal", "active", "shelved", "suppressed"] +InterlockState = Literal["clear", "permissive_missing", "tripped"] + + +def _synthetic_identifier(value: str) -> str: + normalized = value.strip() + if not normalized.startswith("SYNTHETIC."): + raise ValueError("identifiers must begin with SYNTHETIC.") + return normalized + + +class FaceplateValue(BaseModel): + model_config = ConfigDict(frozen=True) + + value: float + unit: str = Field(min_length=1, max_length=24) + source_timestamp: datetime + + +class AssetFaceplate(BaseModel): + """One reusable operator-facing asset summary.""" + + model_config = ConfigDict(frozen=True) + + asset_id: str + label: str = Field(min_length=1, max_length=100) + asset_type: Literal["pump", "valve", "vessel", "heater", "separator"] + primary_value: FaceplateValue + quality: SignalQuality + mode: OperatingMode + alarm_state: AlarmState + interlock_state: InterlockState + detail_route: str = Field(pattern=r"^/operator/assets/[A-Za-z0-9._-]+$") + trend_tags: tuple[str, ...] = Field(min_length=1) + + _validate_asset_id = field_validator("asset_id")(_synthetic_identifier) + + @field_validator("trend_tags") + @classmethod + def _validate_trend_tags(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_synthetic_identifier(value) for value in values) + + +class ProcessArea(BaseModel): + model_config = ConfigDict(frozen=True) + + area_id: str + label: str = Field(min_length=1, max_length=100) + detail_route: str = Field(pattern=r"^/operator/areas/[A-Za-z0-9._-]+$") + assets: tuple[AssetFaceplate, ...] = Field(min_length=1) + + _validate_area_id = field_validator("area_id")(_synthetic_identifier) + + +class ProcessOverview(BaseModel): + model_config = ConfigDict(frozen=True) + + overview_id: str + title: str = Field(min_length=1, max_length=200) + areas: tuple[ProcessArea, ...] = Field(min_length=2) + data_classification: Literal["synthetic"] + not_for_live_control: Literal[True] + + _validate_overview_id = field_validator("overview_id")(_synthetic_identifier) + + +def _asset( + asset_id: str, + label: str, + asset_type: Literal["pump", "valve", "vessel", "heater", "separator"], + value: float, + unit: str, + *, + mode: OperatingMode = "automatic", +) -> AssetFaceplate: + return AssetFaceplate( + asset_id=asset_id, + label=label, + asset_type=asset_type, + primary_value=FaceplateValue( + value=value, + unit=unit, + source_timestamp=datetime(2026, 1, 1, tzinfo=UTC), + ), + quality="simulated", + mode=mode, + alarm_state="normal", + interlock_state="clear", + detail_route=f"/operator/assets/{asset_id}", + trend_tags=(f"{asset_id}.PV", f"{asset_id}.SP"), + ) + + +def synthetic_process_overview() -> ProcessOverview: + """Return the fixed representative process; it contains no plant identifiers.""" + return ProcessOverview( + overview_id="SYNTHETIC.PROCESS", + title="Representative Process Overview", + data_classification="synthetic", + not_for_live_control=True, + areas=( + ProcessArea( + area_id="SYNTHETIC.FEED", + label="Feed Preparation", + detail_route="/operator/areas/SYNTHETIC.FEED", + assets=( + _asset("SYNTHETIC.FEED.PUMP", "Feed Pump", "pump", 62.0, "%"), + _asset("SYNTHETIC.FEED.VALVE", "Feed Valve", "valve", 58.0, "%"), + ), + ), + ProcessArea( + area_id="SYNTHETIC.REACTOR", + label="Reaction", + detail_route="/operator/areas/SYNTHETIC.REACTOR", + assets=( + _asset("SYNTHETIC.REACTOR.VESSEL", "Reactor", "vessel", 72.0, "°C"), + _asset("SYNTHETIC.REACTOR.HEATER", "Heater", "heater", 41.0, "%"), + ), + ), + ProcessArea( + area_id="SYNTHETIC.SEPARATION", + label="Separation", + detail_route="/operator/areas/SYNTHETIC.SEPARATION", + assets=( + _asset( + "SYNTHETIC.SEPARATION.VESSEL", + "Separator", + "separator", + 48.0, + "%", + ), + ), + ), + ), + ) diff --git a/src/p1am_control_system/backend/product_router.py b/src/p1am_control_system/backend/product_router.py new file mode 100644 index 0000000000..efdae1e6d2 --- /dev/null +++ b/src/p1am_control_system/backend/product_router.py @@ -0,0 +1,94 @@ +"""REST adapter for the reusable synthetic control-product contracts.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal + +from availability import AvailabilityHealth, AvailabilityService +from connector_plugins import ( + ConnectorDiagnostic, + ConnectorManager, + ConnectorSample, +) +from fastapi import APIRouter, Depends, HTTPException +from identity import Principal +from notification_policy import ( + NotificationAudit, + NotificationPolicy, + NotificationService, +) +from pydantic import BaseModel, ConfigDict, Field +from synthetic_procedure import ( + ProcedureCommand, + ProcedureEvent, + ProcedureState, + SyntheticProcedure, +) + + +class ProcedureCommandBody(BaseModel): + model_config = ConfigDict(frozen=True) + + reason: str = Field(min_length=1, max_length=500) + + +class ProductStatus(BaseModel): + model_config = ConfigDict(frozen=True) + + procedure_state: ProcedureState + procedure_events: list[ProcedureEvent] + connectors: list[ConnectorDiagnostic] + samples: dict[str, ConnectorSample] + notification_policy: NotificationPolicy + notification_audit: list[NotificationAudit] + availability: AvailabilityHealth + data_classification: Literal["synthetic"] = "synthetic" + not_for_live_control: Literal[True] = True + + +def create_product_router( + procedure: SyntheticProcedure, + connectors: ConnectorManager, + notifications: NotificationService, + availability: AvailabilityService, + operator_dependency: Callable[..., Principal], +) -> APIRouter: + if not all( + ( + isinstance(procedure, SyntheticProcedure), + isinstance(connectors, ConnectorManager), + isinstance(notifications, NotificationService), + isinstance(availability, AvailabilityService), + callable(operator_dependency), + ) + ): + raise TypeError("product router dependencies do not satisfy their contracts") + router = APIRouter(prefix="/api/operator", tags=["control-product"]) + + @router.get("/product-status") + async def product_status() -> ProductStatus: + return ProductStatus( + procedure_state=procedure.state, + procedure_events=procedure.events(), + connectors=connectors.diagnostics(), + samples=connectors.poll(), + notification_policy=notifications.policy, + notification_audit=notifications.audit(), + availability=availability.health(), + ) + + @router.post("/procedure/commands/{command}") + async def procedure_command( + command: ProcedureCommand, + body: ProcedureCommandBody, + principal: Principal = Depends(operator_dependency), # noqa: B008 + ) -> ProcedureEvent: + try: + return procedure.dispatch(command, principal, body.reason) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + return router diff --git a/src/p1am_control_system/backend/protection_management.py b/src/p1am_control_system/backend/protection_management.py new file mode 100644 index 0000000000..557b8d054b --- /dev/null +++ b/src/p1am_control_system/backend/protection_management.py @@ -0,0 +1,175 @@ +"""Synthetic first-out, consequence, and managed-bypass domain.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Literal + +from identity import Principal, Role +from pydantic import BaseModel, ConfigDict, Field, field_validator + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +def _required_text(value: str, name: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError(f"{name} is required") + return normalized + + +class ProtectionCategory(StrEnum): + CONTROL = "control" + INTERLOCK = "interlock" + INDEPENDENT_PROTECTION = "independent_protection" + + +class ProtectionDefinition(BaseModel): + model_config = ConfigDict(frozen=True) + + protection_id: str + category: ProtectionCategory + consequences: tuple[str, ...] = Field(min_length=1) + bypassable: bool + + @field_validator("protection_id") + @classmethod + def _synthetic_only(cls, value: str) -> str: + normalized = _required_text(value, "protection_id") + if not normalized.startswith("SYNTHETIC."): + raise ValueError("protection_id must begin with SYNTHETIC.") + return normalized + + +class BypassRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + protection_id: str + reason: str = Field(min_length=8, max_length=500) + expires_at: datetime + + @field_validator("reason") + @classmethod + def _normalize_reason(cls, value: str) -> str: + return _required_text(value, "reason") + + +class TripRecord(BaseModel): + model_config = ConfigDict(frozen=True) + + protection_id: str + group_id: str + category: ProtectionCategory + consequences: tuple[str, ...] + occurred_at: datetime + first_out: bool + + +class ManagedBypass(BaseModel): + model_config = ConfigDict(frozen=True) + + protection_id: str + actor: str + reason: str + requested_at: datetime + expires_at: datetime + banner_required: Literal[True] = True + active: Literal[True] = True + + +class ProtectionService: + """Thread-local domain service; API audit middleware supplies durable audit.""" + + def __init__( + self, + definitions: Sequence[ProtectionDefinition], + now: Callable[[], datetime], + ) -> None: + indexed = {definition.protection_id: definition for definition in definitions} + if len(indexed) != len(definitions): + raise ValueError("protection identifiers must be unique") + self._definitions = indexed + self._now = now + self._trips: list[TripRecord] = [] + self._bypasses: list[ManagedBypass] = [] + + def _definition(self, protection_id: str) -> ProtectionDefinition: + try: + return self._definitions[protection_id] + except KeyError as exc: + raise KeyError(f"unknown protection: {protection_id}") from exc + + def trip(self, protection_id: str, *, group_id: str) -> TripRecord: + definition = self._definition(protection_id) + normalized_group = _required_text(group_id, "group_id") + first_out = not any( + record.group_id == normalized_group for record in self._trips + ) + record = TripRecord( + protection_id=definition.protection_id, + group_id=normalized_group, + category=definition.category, + consequences=definition.consequences, + occurred_at=self._now(), + first_out=first_out, + ) + self._trips.append(record) + return record + + def request_bypass( + self, request: BypassRequest, principal: Principal + ) -> ManagedBypass: + if principal.role not in {Role.ENGINEER, Role.ADMIN}: + raise PermissionError("engineer or admin role required") + definition = self._definition(request.protection_id) + if not definition.bypassable: + raise ValueError("protection policy is non-bypassable") + requested_at = self._now() + if request.expires_at <= requested_at: + raise ValueError("bypass expiry must be in the future") + if request.expires_at - requested_at > timedelta(hours=24): + raise ValueError("bypass duration cannot exceed 24 hours") + bypass = ManagedBypass( + protection_id=request.protection_id, + actor=principal.subject, + reason=request.reason, + requested_at=requested_at, + expires_at=request.expires_at, + ) + self._bypasses.append(bypass) + return bypass + + def active_bypasses(self) -> list[ManagedBypass]: + now = self._now() + return [bypass for bypass in self._bypasses if bypass.expires_at > now] + + def definitions(self) -> list[ProtectionDefinition]: + return list(self._definitions.values()) + + def trips(self) -> list[TripRecord]: + return list(self._trips) + + +def representative_protections() -> tuple[ProtectionDefinition, ...]: + """Non-confidential protection examples for the synthetic process.""" + return ( + ProtectionDefinition( + protection_id="SYNTHETIC.REACTOR.HIGH_PRESSURE", + category=ProtectionCategory.INTERLOCK, + consequences=("SYNTHETIC.FEED stops", "SYNTHETIC.VENT opens"), + bypassable=True, + ), + ProtectionDefinition( + protection_id="SYNTHETIC.REACTOR.INDEPENDENT_TRIP", + category=ProtectionCategory.INDEPENDENT_PROTECTION, + consequences=("Synthetic heater power removed",), + bypassable=False, + ), + ) diff --git a/src/p1am_control_system/backend/recovery_package.py b/src/p1am_control_system/backend/recovery_package.py new file mode 100644 index 0000000000..65ac5353d8 --- /dev/null +++ b/src/p1am_control_system/backend/recovery_package.py @@ -0,0 +1,187 @@ +"""Checksum-verified configuration recovery packages with no energized state.""" + +from __future__ import annotations + +import hashlib +import io +import json +import zipfile +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from alarm_service import manager_from_routing +from configuration_workflow import ConfigurationRevision, ConfigurationWorkflow +from identity import Principal +from models import RoutingConfig +from pydantic import BaseModel, ConfigDict, Field + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + +PACKAGE_SCHEMA = "p1am.configuration-recovery/v1" +EXPECTED_ENTRIES = frozenset({"manifest.json", "configuration.json"}) +MAX_PACKAGE_BYTES = 5_000_000 +MAX_ENTRY_BYTES = 2_000_000 + + +class RecoveryManifest(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: str = PACKAGE_SCHEMA + created_at: datetime + software_revision: str = Field(min_length=1, max_length=200) + configuration_revision: str = Field(min_length=1, max_length=200) + configuration_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + entries: dict[str, str] + data_classification: str = "configuration_backup" + not_for_live_control: bool = True + energized_state_included: bool = False + limitations: tuple[str, ...] = ( + "Restores configuration into a draft only.", + "Does not contain credentials, runtime commands, or energized state.", + "Requires validation, review, approval, and activation after restore.", + ) + + +@dataclass(frozen=True) +class RecoveryArtifact: + payload: bytes = field(repr=False) + sha256: str + manifest: RecoveryManifest + + +@dataclass(frozen=True) +class VerifiedRecovery: + manifest: RecoveryManifest + configuration: RoutingConfig + package_sha256: str + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _required_revision(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("software_revision must be a non-empty string") + return value.strip() + + +class RecoveryPackageService: + """Create and restore narrowly scoped, de-energized recovery artifacts.""" + + def __init__( + self, + workflow: ConfigurationWorkflow, + software_revision: str, + clock: Callable[[], datetime] | None = None, + ) -> None: + if not isinstance(workflow, ConfigurationWorkflow): + raise TypeError("workflow must be a ConfigurationWorkflow") + self._workflow = workflow + self._software_revision = _required_revision(software_revision) + self._clock = clock or (lambda: datetime.now(UTC)) + self._last_verified_at: datetime | None = None + + @property + def last_verified_at(self) -> datetime | None: + return self._last_verified_at + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + def create(self) -> RecoveryArtifact: + active = self._workflow.active() + if active is None or not active.activation_identity: + raise ValueError("an identified active configuration is required") + configuration = json.dumps( + active.payload.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + manifest = RecoveryManifest( + created_at=self._now(), + software_revision=self._software_revision, + configuration_revision=active.activation_identity, + configuration_sha256=active.payload_sha256, + entries={"configuration.json": _sha256(configuration)}, + ) + manifest_payload = manifest.model_dump_json(indent=2).encode("utf-8") + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", manifest_payload) + archive.writestr("configuration.json", configuration) + payload = output.getvalue() + return RecoveryArtifact( + payload=payload, + sha256=_sha256(payload), + manifest=manifest, + ) + + def verify( + self, + payload: bytes, + expected_sha256: str | None = None, + ) -> VerifiedRecovery: + if not isinstance(payload, bytes): + raise TypeError("payload must be bytes") + if not payload or len(payload) > MAX_PACKAGE_BYTES: + raise ValueError("recovery package size is outside the allowed boundary") + package_sha256 = _sha256(payload) + if expected_sha256 is not None and package_sha256 != expected_sha256.lower(): + raise ValueError("recovery package checksum does not match") + try: + with zipfile.ZipFile(io.BytesIO(payload), "r") as archive: + names = frozenset(archive.namelist()) + if names != EXPECTED_ENTRIES: + raise ValueError("recovery package entries are not allowed") + for info in archive.infolist(): + if info.file_size > MAX_ENTRY_BYTES: + raise ValueError("recovery package entry is too large") + manifest_payload = archive.read("manifest.json") + configuration_payload = archive.read("configuration.json") + except (zipfile.BadZipFile, RuntimeError) as exc: + raise ValueError("recovery package is not a valid archive") from exc + manifest = RecoveryManifest.model_validate_json(manifest_payload) + if manifest.schema_id != PACKAGE_SCHEMA: + raise ValueError("recovery package schema is unsupported") + if not manifest.not_for_live_control or manifest.energized_state_included: + raise ValueError("recovery package violates the de-energized contract") + expected_entry = manifest.entries.get("configuration.json") + if expected_entry != _sha256(configuration_payload): + raise ValueError("configuration entry checksum does not match") + configuration = RoutingConfig.model_validate_json(configuration_payload) + manager_from_routing(configuration) + if ( + _sha256( + json.dumps( + configuration.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ) + != manifest.configuration_sha256 + ): + raise ValueError("configuration identity checksum does not match") + self._last_verified_at = self._now() + return VerifiedRecovery(manifest, configuration, package_sha256) + + def restore_as_draft( + self, + payload: bytes, + principal: Principal, + reason: str, + expected_sha256: str | None = None, + ) -> ConfigurationRevision: + verified = self.verify(payload, expected_sha256) + return self._workflow.create_draft( + verified.configuration, + principal, + reason, + ) diff --git a/src/p1am_control_system/backend/representative_product.py b/src/p1am_control_system/backend/representative_product.py new file mode 100644 index 0000000000..5e70e76db9 --- /dev/null +++ b/src/p1am_control_system/backend/representative_product.py @@ -0,0 +1,93 @@ +"""Non-confidential product demonstration composition for the operator workspace.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from advisory_workspace import AdvisoryService +from availability import AvailabilityPolicy, AvailabilityService +from connector_plugins import ConnectorDescriptor, ConnectorManager +from notification_policy import NotificationPolicy, NotificationService +from synthetic_procedure import SyntheticProcedure + + +class _HealthyConnector: + descriptor = ConnectorDescriptor( + connector_id="SYNTHETIC.CONNECTOR.HEALTHY", + version="1.0.0", + tags=("SYNTHETIC.CONNECTOR.HEALTHY.PV",), + writable_tags=("SYNTHETIC.CONNECTOR.HEALTHY.SP",), + ) + + def read(self) -> dict[str, float]: + return {"SYNTHETIC.CONNECTOR.HEALTHY.PV": 42.0} + + def write(self, tag: str, value: float) -> None: + if tag != "SYNTHETIC.CONNECTOR.HEALTHY.SP": + raise KeyError(tag) + + def diagnostics(self) -> dict[str, object]: + return {"state": "online", "transport": "representative"} + + +class _UnavailableConnector: + descriptor = ConnectorDescriptor( + connector_id="SYNTHETIC.CONNECTOR.UNAVAILABLE", + version="1.0.0", + tags=("SYNTHETIC.CONNECTOR.UNAVAILABLE.PV",), + ) + + def read(self) -> dict[str, float]: + raise ConnectionError("representative offline connector") + + def write(self, tag: str, value: float) -> None: + raise ConnectionError("representative offline connector") + + def diagnostics(self) -> dict[str, object]: + return { # pragma: allowlist secret + "state": "offline", + "password": "demonstration-redaction-value", + } + + +class _AuditOnlyChannel: + def send(self, recipient: str, message: str) -> None: + """No external side effect; the service retains delivery audit only.""" + + +@dataclass(frozen=True) +class RepresentativeProduct: + procedure: SyntheticProcedure + connectors: ConnectorManager + notifications: NotificationService + availability: AvailabilityService + advisories: AdvisoryService + + +def build_representative_product(now: Callable[[], datetime]) -> RepresentativeProduct: + return RepresentativeProduct( + procedure=SyntheticProcedure(now=now), + connectors=ConnectorManager((_HealthyConnector(), _UnavailableConnector())), + notifications=NotificationService( + NotificationPolicy( + initial_delay=timedelta(minutes=1), + escalation_delay=timedelta(minutes=5), + primary_recipient="synthetic.on-call.primary", + escalation_recipient="synthetic.on-call.escalation", + max_deliveries=10, + ), + _AuditOnlyChannel(), + now=now, + ), + availability=AvailabilityService( + AvailabilityPolicy( + recovery_time_objective=timedelta(minutes=5), + recovery_point_objective=timedelta(seconds=30), + max_clock_skew=timedelta(seconds=2), + buffer_capacity=1000, + ) + ), + advisories=AdvisoryService(now=now), + ) diff --git a/src/p1am_control_system/backend/saved_investigation.py b/src/p1am_control_system/backend/saved_investigation.py new file mode 100644 index 0000000000..21196b4115 --- /dev/null +++ b/src/p1am_control_system/backend/saved_investigation.py @@ -0,0 +1,282 @@ +"""Durable, reproducible historian investigations with explicit bad-data policy.""" + +from __future__ import annotations + +import hashlib +import io +import json +import uuid +import zipfile +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Literal, Protocol + +from identity import Principal, Role +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from sqlmodel import Field as SqlField +from sqlmodel import Session, SQLModel + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _synthetic_tag(value: str) -> str: + normalized = value.strip() + if not normalized.startswith("SYNTHETIC."): + raise ValueError("tags and linked records must begin with SYNTHETIC.") + return normalized + + +def _aware(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must include a UTC offset") + return value + + +def _canonical_bytes(model: BaseModel) -> bytes: + return json.dumps( + model.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + + +class BadDataPolicy(StrEnum): + PRESERVE = "preserve" + EXCLUDE = "exclude" + + +class InvestigationQuery(BaseModel): + model_config = ConfigDict(frozen=True) + + tags: tuple[str, ...] = Field(min_length=1, max_length=64) + start: datetime + end: datetime + max_points: int = Field(ge=10, le=100_000) + + @field_validator("tags") + @classmethod + def _tags_are_synthetic(cls, values: tuple[str, ...]) -> tuple[str, ...]: + normalized = tuple(_synthetic_tag(value) for value in values) + if len(set(normalized)) != len(normalized): + raise ValueError("query tags must be unique") + return normalized + + @field_validator("start", "end") + @classmethod + def _timestamps_are_aware(cls, value: datetime) -> datetime: + return _aware(value) + + @model_validator(mode="after") + def _ordered_window(self) -> InvestigationQuery: + if self.end <= self.start: + raise ValueError("end must be after start") + return self + + +class TagMetadata(BaseModel): + model_config = ConfigDict(frozen=True) + + tag: str + description: str = Field(min_length=1, max_length=300) + unit: str = Field(min_length=1, max_length=24) + source: str = Field(min_length=1, max_length=100) + + _tag_is_synthetic = field_validator("tag")(_synthetic_tag) + + +class Transformation(BaseModel): + model_config = ConfigDict(frozen=True) + + operation: Literal["moving_average", "difference", "scale", "offset"] + parameters: dict[str, float | int] + + +class ChartDefinition(BaseModel): + model_config = ConfigDict(frozen=True) + + chart_id: str = Field(min_length=1, max_length=100) + kind: Literal["trend", "scatter", "histogram"] + tags: tuple[str, ...] = Field(min_length=1) + + @field_validator("tags") + @classmethod + def _chart_tags_are_synthetic(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_synthetic_tag(value) for value in values) + + +class InvestigationSpec(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: Literal["p1am.synthetic-investigation/v1"] = ( + "p1am.synthetic-investigation/v1" + ) + title: str = Field(min_length=1, max_length=200) + query: InvestigationQuery + tag_metadata: tuple[TagMetadata, ...] = Field(min_length=1) + transformations: tuple[Transformation, ...] = () + charts: tuple[ChartDefinition, ...] = Field(min_length=1) + annotations: tuple[str, ...] = () + event_ids: tuple[str, ...] = () + bad_data_policy: BadDataPolicy + context: str = Field(min_length=1, max_length=2000) + data_classification: Literal["synthetic"] = "synthetic" + not_for_live_control: Literal[True] = True + + @field_validator("event_ids") + @classmethod + def _event_ids_are_synthetic(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_synthetic_tag(value) for value in values) + + @model_validator(mode="after") + def _metadata_covers_query(self) -> InvestigationSpec: + metadata_tags = {item.tag for item in self.tag_metadata} + if not set(self.query.tags).issubset(metadata_tags): + raise ValueError("tag_metadata must cover every query tag") + query_tags = set(self.query.tags) + if any(not set(chart.tags).issubset(query_tags) for chart in self.charts): + raise ValueError("chart tags must be present in the query") + return self + + +class SavedInvestigation(BaseModel): + model_config = ConfigDict(frozen=True) + + investigation_id: str + version: int = Field(gt=0) + spec: InvestigationSpec + created_by: str + created_at: datetime + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class InvestigationRecord(SQLModel, table=True): # type: ignore[call-arg] + investigation_id: str = SqlField(primary_key=True) + created_at: datetime = SqlField(index=True) + created_by: str + content_sha256: str + document_json: str + + +class InvestigationRepository(Protocol): + def save(self, investigation: SavedInvestigation) -> None: ... + + def get(self, investigation_id: str) -> SavedInvestigation: ... + + +class SqliteInvestigationRepository: + def __init__(self, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + def save(self, investigation: SavedInvestigation) -> None: + record = InvestigationRecord( + investigation_id=investigation.investigation_id, + created_at=investigation.created_at, + created_by=investigation.created_by, + content_sha256=investigation.content_sha256, + document_json=_canonical_bytes(investigation).decode(), + ) + with self._session_factory() as session: + session.add(record) + session.commit() + + def get(self, investigation_id: str) -> SavedInvestigation: + with self._session_factory() as session: + record = session.get(InvestigationRecord, investigation_id) + if record is None: + raise KeyError(f"unknown investigation: {investigation_id}") + # Annotated local: this package uses flat intra-package imports, which + # mypy resolves only when invoked from this directory. CI invokes it + # from the repo root with --follow-imports=skip, where the model + # becomes Any. Pinning the type keeps the check honest either way. + loaded: SavedInvestigation = SavedInvestigation.model_validate_json( + record.document_json + ) + return loaded + + +class InvestigationExportManifest(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: Literal["p1am.synthetic-investigation-package/v1"] = ( + "p1am.synthetic-investigation-package/v1" + ) + investigation_id: str + entries: dict[str, str] + data_classification: Literal["synthetic"] = "synthetic" + + +@dataclass(frozen=True) +class InvestigationArtifact: + payload: bytes = field(repr=False) + sha256: str + manifest: InvestigationExportManifest + + +def _zip_entry(name: str, payload: bytes) -> tuple[zipfile.ZipInfo, bytes]: + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o600 << 16 + return info, payload + + +class InvestigationService: + def __init__( + self, + repository: InvestigationRepository, + now: Callable[[], datetime] | None = None, + ) -> None: + self._repository = repository + self._now = now or (lambda: datetime.now(UTC)) + + def save(self, spec: InvestigationSpec, principal: Principal) -> SavedInvestigation: + if principal.role is Role.VIEWER: + raise PermissionError("operator, engineer, or admin role required") + created_at = _aware(self._now()) + content_sha256 = hashlib.sha256(_canonical_bytes(spec)).hexdigest() + saved = SavedInvestigation( + investigation_id=f"inv-{uuid.uuid4().hex}", + version=1, + spec=spec, + created_by=principal.subject, + created_at=created_at, + content_sha256=content_sha256, + ) + self._repository.save(saved) + return saved + + def get(self, investigation_id: str) -> SavedInvestigation: + return self._repository.get(investigation_id) + + def export(self, investigation_id: str) -> InvestigationArtifact: + investigation = self.get(investigation_id) + investigation_bytes = _canonical_bytes(investigation) + manifest = InvestigationExportManifest( + investigation_id=investigation_id, + entries={ + "investigation.json": hashlib.sha256(investigation_bytes).hexdigest() + }, + ) + manifest_bytes = _canonical_bytes(manifest) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(*_zip_entry("manifest.json", manifest_bytes)) + archive.writestr(*_zip_entry("investigation.json", investigation_bytes)) + payload = buffer.getvalue() + return InvestigationArtifact( + payload=payload, + sha256=hashlib.sha256(payload).hexdigest(), + manifest=manifest, + ) diff --git a/src/p1am_control_system/backend/scenario_evidence.py b/src/p1am_control_system/backend/scenario_evidence.py new file mode 100644 index 0000000000..b1220bc889 --- /dev/null +++ b/src/p1am_control_system/backend/scenario_evidence.py @@ -0,0 +1,317 @@ +"""Isolated synthetic FAT/HIL scenarios and hashed acceptance evidence.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + +SCENARIO_SCHEMA = "p1am.synthetic-scenario/v1" +EVIDENCE_SCHEMA = "p1am.acceptance-evidence/v1" + +ScenarioAction = Literal[ + "set_value", + "set_quality", + "transport_disconnect", + "transport_recover", +] + + +class ScenarioStep(BaseModel): + model_config = ConfigDict(frozen=True) + + step_id: str = Field(min_length=1, max_length=100) + action: ScenarioAction + target: str = Field(min_length=1, max_length=200) + parameters: dict[str, object] + expected: dict[str, object] + timing_window_ms: int = Field(gt=0, le=60_000) + + @field_validator("target") + @classmethod + def _synthetic_target(cls, value: str) -> str: + if not value.startswith("SYNTHETIC."): + raise ValueError("scenario targets must begin with SYNTHETIC.") + return value + + +class ScenarioDefinition(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: Literal["p1am.synthetic-scenario/v1"] = "p1am.synthetic-scenario/v1" + name: str = Field(min_length=1, max_length=200) + data_classification: Literal["synthetic"] + not_for_live_control: Literal[True] + steps: list[ScenarioStep] = Field(min_length=1, max_length=100) + limitations: tuple[str, ...] = ( + "Executes only against an isolated representative in-memory adapter.", + "Does not prove field wiring or independent protection behavior.", + "Timing results exclude live networks, controllers, and equipment.", + ) + + +class SyntheticAlarmRecord(BaseModel): + model_config = ConfigDict(frozen=True) + + alarm_id: str + lifecycle: Literal["unacknowledged", "returned_unacknowledged"] + priority: Literal["high"] = "high" + source: Literal["synthetic_scenario"] = "synthetic_scenario" + + +class SyntheticAuditRecord(BaseModel): + model_config = ConfigDict(frozen=True) + + actor: Literal["synthetic.scenario.runner"] = "synthetic.scenario.runner" + action: ScenarioAction + target: str + outcome: Literal["succeeded"] = "succeeded" + timestamp: datetime + + +class StepObservation(BaseModel): + model_config = ConfigDict(frozen=True) + + step_id: str + started_at: datetime + completed_at: datetime + observed: dict[str, object] + alarms: tuple[SyntheticAlarmRecord, ...] = () + audit_events: tuple[SyntheticAuditRecord, ...] = () + + +class StepEvidence(BaseModel): + model_config = ConfigDict(frozen=True) + + step_id: str + action: ScenarioAction + target: str + started_at: datetime + completed_at: datetime + duration_ms: float = Field(ge=0) + expected: dict[str, object] + observed: dict[str, object] + alarms: tuple[SyntheticAlarmRecord, ...] + audit_events: tuple[SyntheticAuditRecord, ...] + behavior_matched: bool + within_timing_window: bool + passed: bool + diagnostic: str + + +class EvidenceSignoff(BaseModel): + model_config = ConfigDict(frozen=True) + + signoff_required: bool = True + prepared_by: str | None = None + witnessed_by: str | None = None + approved_by: str | None = None + signed_at: datetime | None = None + + +class ScenarioEvidence(BaseModel): + model_config = ConfigDict(frozen=True) + + schema_id: Literal["p1am.acceptance-evidence/v1"] = "p1am.acceptance-evidence/v1" + evidence_id: str + scenario_name: str + scenario_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + software_revision: str + configuration_revision: str + started_at: datetime + completed_at: datetime + passed: bool + results: tuple[StepEvidence, ...] + limitations: tuple[str, ...] + signoff: EvidenceSignoff = EvidenceSignoff() + + +class ScenarioAdapter(Protocol): + async def execute(self, step: ScenarioStep) -> StepObservation: ... + + +def sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def canonical_model_bytes(model: BaseModel) -> bytes: + return json.dumps( + model.model_dump(mode="json"), sort_keys=True, separators=(",", ":") + ).encode("utf-8") + + +def _required_revision(value: object, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + return value.strip() + + +class RepresentativeScenarioAdapter: + """In-memory adapter that has no path to a field driver or runtime control.""" + + def __init__(self, clock: Callable[[], datetime] | None = None) -> None: + self._clock = clock or (lambda: datetime.now(UTC)) + self._state: dict[str, dict[str, object]] = { + "SYNTHETIC.TRANSPORT": {"connected": True} + } + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + async def execute(self, step: ScenarioStep) -> StepObservation: + if not isinstance(step, ScenarioStep): + raise TypeError("step must be a ScenarioStep") + started = self._now() + state = self._state.setdefault(step.target, {}) + if step.action == "transport_disconnect": + state["connected"] = False + elif step.action == "transport_recover": + state["connected"] = True + elif step.action == "set_value": + value = step.parameters.get("value") + if not isinstance(value, int | float): + raise ValueError("set_value requires a numeric value") + state["value"] = value + elif step.action == "set_quality": + quality = step.parameters.get("quality") + if quality not in {"good", "uncertain", "bad", "stale", "simulated"}: + raise ValueError("set_quality requires a canonical quality") + state["quality"] = quality + completed = self._now() + returned = step.action in {"transport_recover"} or ( + step.action == "set_quality" and state.get("quality") == "good" + ) + alarm_id = ( + "SYNTHETIC.COMMUNICATIONS" + if step.action.startswith("transport_") + else "SYNTHETIC.DATA_QUALITY" + ) + return StepObservation( + step_id=step.step_id, + started_at=started, + completed_at=completed, + observed=dict(state), + alarms=( + SyntheticAlarmRecord( + alarm_id=alarm_id, + lifecycle=( + "returned_unacknowledged" if returned else "unacknowledged" + ), + ), + ), + audit_events=( + SyntheticAuditRecord( + action=step.action, + target=step.target, + timestamp=completed, + ), + ), + ) + + +class ScenarioRunner: + """Run validated steps and record failures as evidence rather than hiding them.""" + + def __init__( + self, + adapter: ScenarioAdapter, + software_revision: str, + configuration_revision: str, + clock: Callable[[], datetime] | None = None, + ) -> None: + if not callable(getattr(adapter, "execute", None)): + raise TypeError("adapter must implement execute") + self._adapter = adapter + self._software_revision = _required_revision( + software_revision, "software_revision" + ) + self._configuration_revision = _required_revision( + configuration_revision, "configuration_revision" + ) + self._clock = clock or (lambda: datetime.now(UTC)) + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + @staticmethod + def _step_evidence( + step: ScenarioStep, observation: StepObservation + ) -> StepEvidence: + if observation.step_id != step.step_id: + raise ValueError("adapter returned the wrong step identity") + duration = ( + observation.completed_at - observation.started_at + ).total_seconds() * 1000 + if duration < 0: + raise ValueError("adapter returned a negative step duration") + behavior = all( + observation.observed.get(key) == value + for key, value in step.expected.items() + ) + timing = duration <= step.timing_window_ms + passed = behavior and timing + reasons = [] + if not behavior: + reasons.append("expected behavior did not match") + if not timing: + reasons.append("timing window exceeded") + return StepEvidence( + step_id=step.step_id, + action=step.action, + target=step.target, + started_at=observation.started_at, + completed_at=observation.completed_at, + duration_ms=duration, + expected=step.expected, + observed=observation.observed, + alarms=observation.alarms, + audit_events=observation.audit_events, + behavior_matched=behavior, + within_timing_window=timing, + passed=passed, + diagnostic="passed" if passed else "; ".join(reasons), + ) + + async def run(self, scenario: ScenarioDefinition) -> ScenarioEvidence: + if not isinstance(scenario, ScenarioDefinition): + raise TypeError("scenario must be a ScenarioDefinition") + started = self._now() + results = tuple( + [ + self._step_evidence(step, await self._adapter.execute(step)) + for step in scenario.steps + ] + ) + completed = self._now() + scenario_sha = sha256_bytes(canonical_model_bytes(scenario)) + identity_material = ( + f"{scenario_sha}|{started.isoformat()}|{self._software_revision}|" + f"{self._configuration_revision}" + ).encode() + return ScenarioEvidence( + evidence_id=f"evidence-{sha256_bytes(identity_material)[:20]}", + scenario_name=scenario.name, + scenario_sha256=scenario_sha, + software_revision=self._software_revision, + configuration_revision=self._configuration_revision, + started_at=started, + completed_at=completed, + passed=all(result.passed for result in results), + results=results, + limitations=scenario.limitations, + ) diff --git a/src/p1am_control_system/backend/scenario_router.py b/src/p1am_control_system/backend/scenario_router.py new file mode 100644 index 0000000000..bf2fec2667 --- /dev/null +++ b/src/p1am_control_system/backend/scenario_router.py @@ -0,0 +1,107 @@ +"""REST adapter for isolated synthetic acceptance scenarios.""" + +from __future__ import annotations + +from collections.abc import Callable + +from evidence_package import EvidencePackageService +from fastapi import APIRouter, Depends, HTTPException, Response +from identity import Principal +from scenario_evidence import ( + RepresentativeScenarioAdapter, + ScenarioDefinition, + ScenarioRunner, + ScenarioStep, +) + +IdentityProvider = Callable[[], tuple[str, str]] + + +def representative_scenario() -> ScenarioDefinition: + """Return a generic fixture with no plant names, addresses, or control logic.""" + return ScenarioDefinition( + name="Representative transport and quality recovery", + data_classification="synthetic", + not_for_live_control=True, + steps=[ + ScenarioStep( + step_id="disconnect-transport", + action="transport_disconnect", + target="SYNTHETIC.TRANSPORT", + parameters={}, + expected={"connected": False}, + timing_window_ms=100, + ), + ScenarioStep( + step_id="mark-stale", + action="set_quality", + target="SYNTHETIC.SIGNAL_0", + parameters={"quality": "stale"}, + expected={"quality": "stale"}, + timing_window_ms=100, + ), + ScenarioStep( + step_id="recover-transport", + action="transport_recover", + target="SYNTHETIC.TRANSPORT", + parameters={}, + expected={"connected": True}, + timing_window_ms=100, + ), + ScenarioStep( + step_id="restore-quality", + action="set_quality", + target="SYNTHETIC.SIGNAL_0", + parameters={"quality": "good"}, + expected={"quality": "good"}, + timing_window_ms=100, + ), + ], + ) + + +def create_scenario_router( + identity_provider: IdentityProvider, + admin_dependency: Callable[..., Principal], +) -> APIRouter: + """Build a runner that can only instantiate the isolated representative adapter.""" + if not callable(identity_provider) or not callable(admin_dependency): + raise TypeError("scenario providers must be callable") + router = APIRouter(prefix="/api/acceptance/scenarios", tags=["acceptance"]) + + @router.get("/representative") + async def representative() -> ScenarioDefinition: + return representative_scenario() + + @router.post("/run") + async def run( + scenario: ScenarioDefinition, + _principal: Principal = Depends(admin_dependency), # noqa: B008 + ) -> Response: + try: + software_revision, configuration_revision = identity_provider() + runner = ScenarioRunner( + RepresentativeScenarioAdapter(), + software_revision=software_revision, + configuration_revision=configuration_revision, + ) + evidence = await runner.run(scenario) + artifact = EvidencePackageService().create(scenario, evidence) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return Response( + content=artifact.payload, + media_type="application/zip", + headers={ + "Content-Disposition": ( + "attachment; filename=p1am-acceptance-evidence.zip" + ), + "X-Artifact-SHA256": artifact.sha256, + "X-Evidence-ID": evidence.evidence_id, + "X-Evidence-Passed": str(evidence.passed).lower(), + "X-Data-Classification": "synthetic", + "X-Not-For-Live-Control": "true", + }, + ) + + return router diff --git a/src/p1am_control_system/backend/settings.py b/src/p1am_control_system/backend/settings.py index d98fca7a90..e291a3f6f1 100644 --- a/src/p1am_control_system/backend/settings.py +++ b/src/p1am_control_system/backend/settings.py @@ -5,7 +5,7 @@ from functools import lru_cache from typing import Literal -from pydantic import AliasChoices, Field, field_validator +from pydantic import AliasChoices, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict SQLITE_SYNCHRONOUS_MODES = {"OFF", "NORMAL", "FULL", "EXTRA"} @@ -82,6 +82,65 @@ class P1AMSettings(BaseSettings): default="NORMAL", validation_alias="P1AM_SQLITE_SYNCHRONOUS", ) + # --- Remote plant historian (TimescaleDB) forwarding ------------------ + # Off by default: enabling this is a deployment decision, and a backend + # that has never been configured for a plant historian must behave exactly + # as it did before. SQLite remains the local source of truth either way; + # forwarding is strictly additive and best-effort. + timescale_enabled: bool = Field( + default=False, + validation_alias="P1AM_TIMESCALE_ENABLED", + description=( + "Enable best-effort forwarding of historian samples to a remote " + "TimescaleDB plant historian. Requires timescale_dsn. The local " + "SQLite historian is unaffected." + ), + ) + timescale_dsn: str = Field( + default="", + validation_alias="P1AM_TIMESCALE_DSN", + description=( + "libpq connection string for the plant historian. Never logged in " + "full — see timescale_writer.redact_dsn." + ), + ) + timescale_queue_max: int = Field( + default=100_000, + ge=1, + validation_alias="P1AM_TIMESCALE_QUEUE_MAX", + description=( + "Bounded forward-queue depth. On overflow the oldest samples are " + "dropped and counted. Bounded deliberately: an unbounded queue on " + "the control Pi is an out-of-memory crash of the controller." + ), + ) + timescale_batch_size: int = Field( + default=1_000, + ge=1, + validation_alias="P1AM_TIMESCALE_BATCH_SIZE", + description="Maximum samples per remote round-trip.", + ) + timescale_flush_interval_s: float = Field( + default=1.0, + gt=0.0, + validation_alias="P1AM_TIMESCALE_FLUSH_INTERVAL_S", + description="Maximum time a partial batch waits before being shipped.", + ) + timescale_connect_timeout_s: float = Field( + default=5.0, + gt=0.0, + validation_alias="P1AM_TIMESCALE_CONNECT_TIMEOUT_S", + description="Fail-fast bound on historian connection establishment.", + ) + timescale_shutdown_flush_s: float = Field( + default=5.0, + gt=0.0, + validation_alias="P1AM_TIMESCALE_SHUTDOWN_FLUSH_S", + description=( + "Bound on the shutdown flush. Application shutdown must never hang " + "waiting on an unreachable historian." + ), + ) require_read_auth: bool = Field( default=False, validation_alias="P1AM_REQUIRE_READ_AUTH", @@ -94,6 +153,24 @@ class P1AMSettings(BaseSettings): ), ) + @model_validator(mode="after") + def _require_dsn_when_timescale_enabled(self) -> P1AMSettings: + """Reject an enabled-but-unconfigured plant historian at startup. + + Failing loudly here is deliberate. The alternative — starting with + forwarding "on" but no destination — produces a plant where everyone + believes history is being recorded off-box and it is not. A historian + that is silently absent is worse than one that is openly disabled, + because nobody goes looking for the gap until they need the data. + """ + if self.timescale_enabled and not self.timescale_dsn.strip(): + raise ValueError( + "P1AM_TIMESCALE_ENABLED is true but P1AM_TIMESCALE_DSN is empty. " + "Set a connection string, or disable forwarding explicitly with " + "P1AM_TIMESCALE_ENABLED=false." + ) + return self + @field_validator("plc_driver", mode="before") @classmethod def _normalize_driver(cls, value: object) -> str: diff --git a/src/p1am_control_system/backend/shift_log.py b/src/p1am_control_system/backend/shift_log.py new file mode 100644 index 0000000000..ad40aad146 --- /dev/null +++ b/src/p1am_control_system/backend/shift_log.py @@ -0,0 +1,237 @@ +"""Durable attributable shift entries, sign-off, and handover acknowledgment.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Literal, Protocol + +from identity import Principal, Role +from pydantic import BaseModel, ConfigDict, Field, field_validator +from sqlmodel import Field as SqlField +from sqlmodel import SQLModel + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _synthetic_id(value: str) -> str: + normalized = value.strip() + if not normalized.startswith("SYNTHETIC."): + raise ValueError("linked identifiers must begin with SYNTHETIC.") + return normalized + + +def _aware(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("timestamps must include a UTC offset") + return value + + +def _restore_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +def _required_text(value: str, name: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError(f"{name} is required") + return normalized + + +def _canonical_bytes(model: BaseModel) -> bytes: + return json.dumps( + model.model_dump(mode="json"), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + + +class EventReference(BaseModel): + model_config = ConfigDict(frozen=True) + + event_id: str + occurred_at: datetime + + _event_is_synthetic = field_validator("event_id")(_synthetic_id) + _timestamp_is_aware = field_validator("occurred_at")(_aware) + + +class TrendReference(BaseModel): + model_config = ConfigDict(frozen=True) + + investigation_id: str + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + _investigation_is_synthetic = field_validator("investigation_id")(_synthetic_id) + + +class ShiftEntryDraft(BaseModel): + model_config = ConfigDict(frozen=True) + + shift_id: str + run_id: str + summary: str = Field(min_length=1, max_length=4000) + unresolved_actions: tuple[str, ...] = () + event_references: tuple[EventReference, ...] = () + trend_references: tuple[TrendReference, ...] = () + + _shift_is_synthetic = field_validator("shift_id")(_synthetic_id) + _run_is_synthetic = field_validator("run_id")(_synthetic_id) + + @field_validator("summary") + @classmethod + def _summary_required(cls, value: str) -> str: + return _required_text(value, "summary") + + @field_validator("unresolved_actions") + @classmethod + def _actions_required(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_required_text(value, "unresolved action") for value in values) + + +class ShiftEntry(BaseModel): + model_config = ConfigDict(frozen=True) + + entry_id: str + shift_id: str + run_id: str + summary: str + unresolved_actions: tuple[str, ...] + event_references: tuple[EventReference, ...] + trend_references: tuple[TrendReference, ...] + created_by: str + created_at: datetime + data_classification: Literal["synthetic"] = "synthetic" + + +class ShiftSignoff(BaseModel): + model_config = ConfigDict(frozen=True) + + entry_id: str + signed_by: str + signed_at: datetime + content_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class HandoverAcknowledgment(BaseModel): + model_config = ConfigDict(frozen=True) + + entry_id: str + acknowledged_by: str + acknowledged_at: datetime + note: str + + +class ShiftEntryRecord(SQLModel, table=True): # type: ignore[call-arg] + entry_id: str = SqlField(primary_key=True) + shift_id: str = SqlField(index=True) + run_id: str = SqlField(index=True) + summary: str + unresolved_actions_json: str + event_references_json: str + trend_references_json: str + created_by: str = SqlField(index=True) + created_at: datetime = SqlField(index=True) + + +class ShiftSignoffRecord(SQLModel, table=True): # type: ignore[call-arg] + entry_id: str = SqlField(primary_key=True, foreign_key="shiftentryrecord.entry_id") + signed_by: str + signed_at: datetime + content_sha256: str + + +class HandoverAcknowledgmentRecord(SQLModel, table=True): # type: ignore[call-arg] + entry_id: str = SqlField(primary_key=True, foreign_key="shiftentryrecord.entry_id") + acknowledged_by: str + acknowledged_at: datetime + note: str + + +class ShiftLogRepository(Protocol): + def append(self, entry: ShiftEntry) -> None: ... + + def get(self, entry_id: str) -> ShiftEntry: ... + + def search(self, query: str) -> list[ShiftEntry]: ... + + def sign_off(self, signoff: ShiftSignoff) -> None: ... + + def signoff(self, entry_id: str) -> ShiftSignoff | None: ... + + def acknowledge(self, acknowledgment: HandoverAcknowledgment) -> None: ... + + def handover(self, entry_id: str) -> HandoverAcknowledgment | None: ... + + +class ShiftLogService: + def __init__( + self, + repository: ShiftLogRepository, + now: Callable[[], datetime] | None = None, + ) -> None: + self._repository = repository + self._now = now or (lambda: datetime.now(UTC)) + + @staticmethod + def _authorize(principal: Principal) -> None: + if principal.role is Role.VIEWER: + raise PermissionError("operator, engineer, or admin role required") + + def append(self, draft: ShiftEntryDraft, principal: Principal) -> ShiftEntry: + self._authorize(principal) + entry = ShiftEntry( + entry_id=f"shift-entry-{uuid.uuid4().hex}", + **draft.model_dump(), + created_by=principal.subject, + created_at=_aware(self._now()), + ) + self._repository.append(entry) + return entry + + def search(self, query: str) -> list[ShiftEntry]: + return self._repository.search(query) + + def sign_off(self, entry_id: str, principal: Principal) -> ShiftSignoff: + self._authorize(principal) + if self._repository.signoff(entry_id) is not None: + raise ValueError("shift entry is already signed off") + entry = self._repository.get(entry_id) + signoff = ShiftSignoff( + entry_id=entry_id, + signed_by=principal.subject, + signed_at=_aware(self._now()), + content_sha256=hashlib.sha256(_canonical_bytes(entry)).hexdigest(), + ) + self._repository.sign_off(signoff) + return signoff + + def acknowledge_handover( + self, + entry_id: str, + principal: Principal, + note: str, + ) -> HandoverAcknowledgment: + self._authorize(principal) + if self._repository.signoff(entry_id) is None: + raise ValueError("shift entry must be signed off before handover") + if self._repository.handover(entry_id) is not None: + raise ValueError("handover is already acknowledged") + acknowledgment = HandoverAcknowledgment( + entry_id=entry_id, + acknowledged_by=principal.subject, + acknowledged_at=_aware(self._now()), + note=_required_text(note, "handover note"), + ) + self._repository.acknowledge(acknowledgment) + return acknowledgment + + def handover(self, entry_id: str) -> HandoverAcknowledgment | None: + return self._repository.handover(entry_id) diff --git a/src/p1am_control_system/backend/shift_log_repository.py b/src/p1am_control_system/backend/shift_log_repository.py new file mode 100644 index 0000000000..03179f5e8c --- /dev/null +++ b/src/p1am_control_system/backend/shift_log_repository.py @@ -0,0 +1,150 @@ +"""SQLite persistence and database guards for the shift-log domain.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +from shift_log import ( + EventReference, + HandoverAcknowledgment, + HandoverAcknowledgmentRecord, + ShiftEntry, + ShiftEntryRecord, + ShiftSignoff, + ShiftSignoffRecord, + TrendReference, + _restore_utc, +) +from sqlalchemy import text +from sqlmodel import Session, col, select + +_GUARDS = ( + """CREATE TRIGGER IF NOT EXISTS signed_shift_entry_no_update + BEFORE UPDATE ON shiftentryrecord + WHEN EXISTS (SELECT 1 FROM shiftsignoffrecord WHERE entry_id = OLD.entry_id) + BEGIN SELECT RAISE(ABORT, 'signed shift entries are append-only'); END""", + """CREATE TRIGGER IF NOT EXISTS signed_shift_entry_no_delete + BEFORE DELETE ON shiftentryrecord + WHEN EXISTS (SELECT 1 FROM shiftsignoffrecord WHERE entry_id = OLD.entry_id) + BEGIN SELECT RAISE(ABORT, 'signed shift entries are append-only'); END""", + """CREATE TRIGGER IF NOT EXISTS shift_signoff_no_update + BEFORE UPDATE ON shiftsignoffrecord + BEGIN SELECT RAISE(ABORT, 'shift signoffs are append-only'); END""", + """CREATE TRIGGER IF NOT EXISTS shift_signoff_no_delete + BEFORE DELETE ON shiftsignoffrecord + BEGIN SELECT RAISE(ABORT, 'shift signoffs are append-only'); END""", +) + + +class SqliteShiftLogRepository: + def __init__(self, session_factory: Callable[[], Session]) -> None: + self._session_factory = session_factory + + @staticmethod + def _ensure_guards(session: Session) -> None: + for statement in _GUARDS: + session.execute(text(statement)) + + @staticmethod + def _entry(record: ShiftEntryRecord) -> ShiftEntry: + return ShiftEntry( + entry_id=record.entry_id, + shift_id=record.shift_id, + run_id=record.run_id, + summary=record.summary, + unresolved_actions=tuple(json.loads(record.unresolved_actions_json)), + event_references=tuple( + EventReference.model_validate(item) + for item in json.loads(record.event_references_json) + ), + trend_references=tuple( + TrendReference.model_validate(item) + for item in json.loads(record.trend_references_json) + ), + created_by=record.created_by, + created_at=_restore_utc(record.created_at), + ) + + def append(self, entry: ShiftEntry) -> None: + record = ShiftEntryRecord( + entry_id=entry.entry_id, + shift_id=entry.shift_id, + run_id=entry.run_id, + summary=entry.summary, + unresolved_actions_json=json.dumps(entry.unresolved_actions), + event_references_json=json.dumps( + [item.model_dump(mode="json") for item in entry.event_references] + ), + trend_references_json=json.dumps( + [item.model_dump(mode="json") for item in entry.trend_references] + ), + created_by=entry.created_by, + created_at=entry.created_at, + ) + with self._session_factory() as session: + self._ensure_guards(session) + session.add(record) + session.commit() + + def get(self, entry_id: str) -> ShiftEntry: + with self._session_factory() as session: + record = session.get(ShiftEntryRecord, entry_id) + if record is None: + raise KeyError(f"unknown shift entry: {entry_id}") + return self._entry(record) + + def search(self, query: str) -> list[ShiftEntry]: + needle = query.strip().casefold() + with self._session_factory() as session: + records = session.exec( + select(ShiftEntryRecord).order_by( + col(ShiftEntryRecord.created_at).desc() + ) + ).all() + entries = [self._entry(record) for record in records] + if not needle: + return entries + return [ + entry + for entry in entries + if needle + in " ".join( + (entry.summary, entry.shift_id, entry.run_id, *entry.unresolved_actions) + ).casefold() + ] + + def sign_off(self, signoff: ShiftSignoff) -> None: + with self._session_factory() as session: + self._ensure_guards(session) + session.add(ShiftSignoffRecord(**signoff.model_dump())) + session.commit() + + def signoff(self, entry_id: str) -> ShiftSignoff | None: + with self._session_factory() as session: + record = session.get(ShiftSignoffRecord, entry_id) + if record is None: + return None + return ShiftSignoff( + entry_id=record.entry_id, + signed_by=record.signed_by, + signed_at=_restore_utc(record.signed_at), + content_sha256=record.content_sha256, + ) + + def acknowledge(self, acknowledgment: HandoverAcknowledgment) -> None: + with self._session_factory() as session: + session.add(HandoverAcknowledgmentRecord(**acknowledgment.model_dump())) + session.commit() + + def handover(self, entry_id: str) -> HandoverAcknowledgment | None: + with self._session_factory() as session: + record = session.get(HandoverAcknowledgmentRecord, entry_id) + if record is None: + return None + return HandoverAcknowledgment( + entry_id=record.entry_id, + acknowledged_by=record.acknowledged_by, + acknowledged_at=_restore_utc(record.acknowledged_at), + note=record.note, + ) diff --git a/src/p1am_control_system/backend/signal_quality.py b/src/p1am_control_system/backend/signal_quality.py new file mode 100644 index 0000000000..5eafef3a7b --- /dev/null +++ b/src/p1am_control_system/backend/signal_quality.py @@ -0,0 +1,226 @@ +"""Canonical value, timing, quality, diagnostic, source, and sequence model.""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from types import MappingProxyType +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +try: + from datetime import UTC +except ImportError: # Python 3.10 support + UTC = timezone.utc # noqa: UP017 + + +class SignalQuality(StrEnum): + """Small, transport-stable signal quality vocabulary.""" + + GOOD = "good" + UNCERTAIN = "uncertain" + BAD = "bad" + STALE = "stale" + SIMULATED = "simulated" + + +_ALARM_ELIGIBLE = frozenset( + {SignalQuality.GOOD, SignalQuality.UNCERTAIN, SignalQuality.SIMULATED} +) + + +def _required_text(value: object, field_name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{field_name} must be a string") + normalized = value.strip() + if not normalized: + raise ValueError(f"{field_name} must be non-empty") + return normalized + + +def _aware(value: object, field_name: str) -> datetime: + if not isinstance(value, datetime): + raise TypeError(f"{field_name} must be a datetime") + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError(f"{field_name} must be timezone-aware") + return value + + +@dataclass(frozen=True) +class SignalSample: + """One fully attributed signal sample at a specific scan sequence.""" + + value: float + source_timestamp: datetime + server_timestamp: datetime + quality: SignalQuality + diagnostic_reason: str | None + sequence: int + source: str + + def __post_init__(self) -> None: + try: + value = float(self.value) + except (TypeError, ValueError) as exc: + raise TypeError("value must be numeric") from exc + if not math.isfinite(value): + raise ValueError("value must be finite") + object.__setattr__(self, "value", value) + object.__setattr__( + self, + "source_timestamp", + _aware(self.source_timestamp, "source_timestamp"), + ) + object.__setattr__( + self, + "server_timestamp", + _aware(self.server_timestamp, "server_timestamp"), + ) + if self.source_timestamp > self.server_timestamp: + raise ValueError("source_timestamp cannot be after server_timestamp") + if not isinstance(self.quality, SignalQuality): + raise TypeError("quality must be a SignalQuality") + if not isinstance(self.sequence, int) or self.sequence < 1: + raise ValueError("sequence must be a positive integer") + object.__setattr__(self, "source", _required_text(self.source, "source")) + if self.quality is SignalQuality.GOOD: + if self.diagnostic_reason is not None: + raise ValueError("good quality cannot have a diagnostic_reason") + else: + if self.diagnostic_reason is None: + raise ValueError("degraded quality requires a diagnostic_reason") + object.__setattr__( + self, + "diagnostic_reason", + _required_text(self.diagnostic_reason, "diagnostic_reason"), + ) + + def age_seconds(self, now: datetime) -> float: + """Return source age at an aware reference time.""" + return (_aware(now, "now") - self.source_timestamp).total_seconds() + + def to_payload(self) -> dict[str, object]: + """Return the canonical JSON-safe wire representation.""" + return { + "value": self.value, + "source_timestamp": self.source_timestamp.isoformat(), + "server_timestamp": self.server_timestamp.isoformat(), + "quality": self.quality.value, + "diagnostic_reason": self.diagnostic_reason, + "sequence": self.sequence, + "source": self.source, + } + + +@dataclass(frozen=True) +class SignalFrame: + """Immutable scan of samples sharing one server time and sequence.""" + + samples: Mapping[str, SignalSample] + server_timestamp: datetime + sequence: int + + def __post_init__(self) -> None: + if not isinstance(self.samples, Mapping): + raise TypeError("samples must be a mapping") + if not self.samples: + raise ValueError("samples must contain at least one signal") + timestamp = _aware(self.server_timestamp, "server_timestamp") + normalized: dict[str, SignalSample] = {} + for name, sample in self.samples.items(): + tag_name = _required_text(name, "signal name") + if not isinstance(sample, SignalSample): + raise TypeError("samples must contain SignalSample values") + if sample.server_timestamp != timestamp or sample.sequence != self.sequence: + raise ValueError("all samples must share frame time and sequence") + normalized[tag_name] = sample + object.__setattr__(self, "samples", MappingProxyType(normalized)) + + @property + def values(self) -> dict[str, float]: + return {name: sample.value for name, sample in self.samples.items()} + + @property + def alarm_eligible(self) -> bool: + return all( + sample.quality in _ALARM_ELIGIBLE for sample in self.samples.values() + ) + + def to_payload(self) -> dict[str, object]: + return {name: sample.to_payload() for name, sample in self.samples.items()} + + +class SignalFrameFactory: + """Sequence and source-time owner for raw driver scan adaptation.""" + + def __init__(self, clock: Callable[[], datetime] | None = None) -> None: + self._clock = clock or (lambda: datetime.now(UTC)) + self._sequence = 0 + self._last_source_times: dict[str, datetime] = {} + + def _next( + self, + values: dict[str, float], + quality: SignalQuality, + source: str, + reason: str | None, + retain_source_time: bool, + ) -> SignalFrame: + if not isinstance(values, dict): + raise TypeError("values must be a dict") + if not values: + raise ValueError("values must contain at least one signal") + now = _aware(self._clock(), "clock result") + self._sequence += 1 + samples: dict[str, SignalSample] = {} + for name, value in values.items(): + source_time = ( + self._last_source_times.get(name, now) if retain_source_time else now + ) + sample = SignalSample( + value=value, + source_timestamp=source_time, + server_timestamp=now, + quality=quality, + diagnostic_reason=reason, + sequence=self._sequence, + source=source, + ) + samples[name] = sample + if not retain_source_time: + self._last_source_times[name] = now + return SignalFrame(samples, now, self._sequence) + + def good(self, values: dict[str, float], source: str = "driver") -> SignalFrame: + return self._next(values, SignalQuality.GOOD, source, None, False) + + def stale( + self, + values: dict[str, float], + source: str = "driver", + reason: str = "read_failed", + ) -> SignalFrame: + return self._next(values, SignalQuality.STALE, source, reason, True) + + def simulated( + self, + values: dict[str, float], + source: str = "simulator", + ) -> SignalFrame: + return self._next( + values, + SignalQuality.SIMULATED, + source, + "synthetic_source", + False, + ) diff --git a/src/p1am_control_system/backend/synthetic_procedure.py b/src/p1am_control_system/backend/synthetic_procedure.py new file mode 100644 index 0000000000..a6cd8e5e53 --- /dev/null +++ b/src/p1am_control_system/backend/synthetic_procedure.py @@ -0,0 +1,152 @@ +"""Bounded simulator-only procedure state machine with attributable events.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Literal + +from identity import Principal, Role +from pydantic import BaseModel, ConfigDict + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + + +class ProcedureState(StrEnum): + IDLE = "idle" + STARTING = "starting" + RUNNING = "running" + HOLDING = "holding" + STOPPING = "stopping" + ABORTED = "aborted" + RECOVERING = "recovering" + + +class ProcedureCommand(StrEnum): + START = "start" + RUN = "run" + HOLD = "hold" + RESUME = "resume" + STOP = "stop" + COMPLETE = "complete" + ABORT = "abort" + RECOVER = "recover" + TIMEOUT = "timeout" + + +class ProcedureEvent(BaseModel): + model_config = ConfigDict(frozen=True) + + sequence: int + command: ProcedureCommand + before: ProcedureState + after: ProcedureState + actor: str + reason: str + occurred_at: datetime + deadline: datetime | None + data_classification: Literal["synthetic"] = "synthetic" + not_for_live_control: Literal[True] = True + + +_TRANSITIONS = { + (ProcedureState.IDLE, ProcedureCommand.START): ProcedureState.STARTING, + (ProcedureState.STARTING, ProcedureCommand.RUN): ProcedureState.RUNNING, + (ProcedureState.RUNNING, ProcedureCommand.HOLD): ProcedureState.HOLDING, + (ProcedureState.HOLDING, ProcedureCommand.RESUME): ProcedureState.RUNNING, + (ProcedureState.RUNNING, ProcedureCommand.STOP): ProcedureState.STOPPING, + (ProcedureState.HOLDING, ProcedureCommand.STOP): ProcedureState.STOPPING, + (ProcedureState.STOPPING, ProcedureCommand.COMPLETE): ProcedureState.IDLE, + (ProcedureState.ABORTED, ProcedureCommand.RECOVER): ProcedureState.RECOVERING, + (ProcedureState.RECOVERING, ProcedureCommand.COMPLETE): ProcedureState.IDLE, +} +_BOUNDED_STATES = { + ProcedureState.STARTING, + ProcedureState.STOPPING, + ProcedureState.RECOVERING, +} + + +class SyntheticProcedure: + def __init__( + self, + now: Callable[[], datetime], + transition_timeout: timedelta = timedelta(minutes=2), + ) -> None: + if transition_timeout <= timedelta(0): + raise ValueError("transition_timeout must be positive") + self._now = now + self._timeout = transition_timeout + self._state = ProcedureState.IDLE + self._deadline: datetime | None = None + self._events: list[ProcedureEvent] = [] + + @property + def state(self) -> ProcedureState: + return self._state + + def events(self) -> list[ProcedureEvent]: + return list(self._events) + + def _record( + self, + command: ProcedureCommand, + after: ProcedureState, + actor: str, + reason: str, + ) -> ProcedureEvent: + occurred_at = self._now() + before = self._state + self._state = after + self._deadline = ( + occurred_at + self._timeout if after in _BOUNDED_STATES else None + ) + event = ProcedureEvent( + sequence=len(self._events) + 1, + command=command, + before=before, + after=after, + actor=actor, + reason=reason.strip(), + occurred_at=occurred_at, + deadline=self._deadline, + ) + self._events.append(event) + return event + + def dispatch( + self, + command: ProcedureCommand, + principal: Principal, + reason: str, + ) -> ProcedureEvent: + if principal.role is Role.VIEWER: + raise PermissionError("operator, engineer, or admin role required") + if not reason.strip(): + raise ValueError("transition reason is required") + if command is ProcedureCommand.ABORT and self._state is not ProcedureState.IDLE: + after = ProcedureState.ABORTED + else: + try: + after = _TRANSITIONS[(self._state, command)] + except KeyError as exc: + raise ValueError( + f"{command.value} is not allowed from {self._state.value}" + ) from exc + return self._record(command, after, principal.subject, reason) + + def enforce_deadline(self) -> ProcedureEvent | None: + if self._deadline is None or self._now() <= self._deadline: + return None + return self._record( + ProcedureCommand.TIMEOUT, + ProcedureState.ABORTED, + "synthetic.procedure.supervisor", + "Bounded transition deadline exceeded", + ) diff --git a/src/p1am_control_system/backend/system_health.py b/src/p1am_control_system/backend/system_health.py new file mode 100644 index 0000000000..1521c1c09d --- /dev/null +++ b/src/p1am_control_system/backend/system_health.py @@ -0,0 +1,254 @@ +"""Deployment identity and bounded system-health aggregation.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from configuration_workflow import ConfigurationWorkflow +from pydantic import BaseModel, ConfigDict, Field +from recovery_package import RecoveryPackageService +from sqlalchemy import Engine + +if TYPE_CHECKING: + # Type checkers must see the real 3.11 symbol; TYPE_CHECKING is always + # true for them and always false at runtime, so this needs no version + # test and never degrades StrEnum members to bare `str`. + from enum import StrEnum +else: + from enum_compat import StrEnum + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +class HealthStatus(StrEnum): + GOOD = "good" + DEGRADED = "degraded" + BAD = "bad" + + +class DeploymentIdentity(BaseModel): + model_config = ConfigDict(frozen=True) + + software_revision: str = Field(min_length=1) + configuration_revision: str = Field(min_length=1) + configuration_sha256: str | None + configuration_state: str + + +class HealthCheck(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str = Field(min_length=1) + status: HealthStatus + detail: str = Field(min_length=1, max_length=500) + + +class SystemHealthReport(BaseModel): + model_config = ConfigDict(frozen=True) + + generated_at: datetime + overall: HealthStatus + identity: DeploymentIdentity + checks: tuple[HealthCheck, ...] + + +def _required_revision(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("software_revision must be a non-empty string") + return value.strip() + + +class SystemHealthService: + """Aggregate independent health providers without conflating their status.""" + + def __init__( + self, + workflow: ConfigurationWorkflow, + recovery: RecoveryPackageService, + engine: Engine, + software_revision: str, + plc_connected: Callable[[], bool], + simulator_available: Callable[[], bool], + clock_synchronized: Callable[[], bool | None], + storage_free_bytes: Callable[[], int], + service_running: Callable[[], bool], + driver_identity: Callable[[], str], + clock: Callable[[], datetime] | None = None, + ) -> None: + if not isinstance(workflow, ConfigurationWorkflow): + raise TypeError("workflow must be a ConfigurationWorkflow") + if not isinstance(recovery, RecoveryPackageService): + raise TypeError("recovery must be a RecoveryPackageService") + if not isinstance(engine, Engine): + raise TypeError("engine must be an Engine") + providers = ( + plc_connected, + simulator_available, + clock_synchronized, + storage_free_bytes, + service_running, + driver_identity, + ) + if not all(callable(provider) for provider in providers): + raise TypeError("health providers must be callable") + self._workflow = workflow + self._recovery = recovery + self._engine = engine + self._software_revision = _required_revision(software_revision) + self._plc_connected = plc_connected + self._simulator_available = simulator_available + self._clock_synchronized = clock_synchronized + self._storage_free_bytes = storage_free_bytes + self._service_running = service_running + self._driver_identity = driver_identity + self._clock = clock or (lambda: datetime.now(UTC)) + + def _now(self) -> datetime: + now = self._clock() + if not isinstance(now, datetime) or now.tzinfo is None: + raise ValueError("clock must return an aware datetime") + return now + + def identity(self) -> DeploymentIdentity: + active = self._workflow.active() + if active is None: + return DeploymentIdentity( + software_revision=self._software_revision, + configuration_revision="unversioned", + configuration_sha256=None, + configuration_state="none", + ) + return DeploymentIdentity( + software_revision=self._software_revision, + configuration_revision=active.activation_identity or active.revision_id, + configuration_sha256=active.payload_sha256, + configuration_state=active.state.value, + ) + + def _database_check(self) -> HealthCheck: + try: + with self._engine.connect() as connection: + result = connection.exec_driver_sql("PRAGMA quick_check").scalar_one() + except Exception as exc: # noqa: BLE001 - report, do not obscure other checks + return HealthCheck( + name="database", + status=HealthStatus.BAD, + detail=f"Database check failed: {type(exc).__name__}", + ) + status = HealthStatus.GOOD if str(result).lower() == "ok" else HealthStatus.BAD + return HealthCheck(name="database", status=status, detail=str(result)) + + def report(self) -> SystemHealthReport: + identity = self.identity() + primary_connected = bool(self._plc_connected()) + simulator_available = bool(self._simulator_available()) + clock_synchronized = self._clock_synchronized() + free_bytes = self._storage_free_bytes() + if not isinstance(free_bytes, int) or free_bytes < 0: + raise ValueError( + "storage_free_bytes provider must return a non-negative int" + ) + service_running = bool(self._service_running()) + driver_identity = self._driver_identity() + if not isinstance(driver_identity, str) or not driver_identity.strip(): + raise ValueError("driver_identity provider must return a non-empty string") + storage_status = ( + HealthStatus.GOOD + if free_bytes >= 1_000_000_000 + else ( + HealthStatus.DEGRADED if free_bytes >= 100_000_000 else HealthStatus.BAD + ) + ) + clock_status = ( + HealthStatus.GOOD + if clock_synchronized is True + else ( + HealthStatus.BAD + if clock_synchronized is False + else HealthStatus.DEGRADED + ) + ) + checks = ( + self._database_check(), + HealthCheck( + name="primary_transport", + status=( + HealthStatus.GOOD if primary_connected else HealthStatus.DEGRADED + ), + detail=("Connected" if primary_connected else "Disconnected"), + ), + HealthCheck( + name="simulator", + status=(HealthStatus.GOOD if simulator_available else HealthStatus.BAD), + detail=("Available" if simulator_available else "Unavailable"), + ), + HealthCheck( + name="clock", + status=clock_status, + detail=( + "Synchronized" + if clock_synchronized is True + else ( + "Not synchronized" + if clock_synchronized is False + else "Synchronization source not verified" + ) + ), + ), + HealthCheck( + name="storage", + status=storage_status, + detail=f"{free_bytes} bytes free", + ), + HealthCheck( + name="service", + status=HealthStatus.GOOD if service_running else HealthStatus.BAD, + detail="Running" if service_running else "Stopped", + ), + HealthCheck( + name="driver", + status=( + HealthStatus.GOOD if primary_connected else HealthStatus.DEGRADED + ), + detail=driver_identity.strip(), + ), + HealthCheck( + name="configuration_identity", + status=( + HealthStatus.GOOD + if identity.configuration_sha256 + else HealthStatus.DEGRADED + ), + detail=identity.configuration_revision, + ), + HealthCheck( + name="recovery_verification", + status=( + HealthStatus.GOOD + if self._recovery.last_verified_at + else HealthStatus.DEGRADED + ), + detail=( + self._recovery.last_verified_at.isoformat() + if self._recovery.last_verified_at + else "No package verified in this process" + ), + ), + ) + ranks = { + HealthStatus.GOOD: 0, + HealthStatus.DEGRADED: 1, + HealthStatus.BAD: 2, + } + overall = max((check.status for check in checks), key=ranks.__getitem__) + return SystemHealthReport( + generated_at=self._now(), + overall=overall, + identity=identity, + checks=checks, + ) diff --git a/src/p1am_control_system/backend/system_router.py b/src/p1am_control_system/backend/system_router.py new file mode 100644 index 0000000000..0663165567 --- /dev/null +++ b/src/p1am_control_system/backend/system_router.py @@ -0,0 +1,76 @@ +"""REST surface for recovery packages, identity, and system health.""" + +from __future__ import annotations + +from collections.abc import Callable + +from configuration_workflow import ConfigurationRevision +from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response +from identity import Principal +from recovery_package import RecoveryPackageService +from system_health import DeploymentIdentity, SystemHealthReport, SystemHealthService + + +def create_system_router( + recovery: RecoveryPackageService, + health: SystemHealthService, + engineer_dependency: Callable[..., Principal], + admin_dependency: Callable[..., Principal], +) -> APIRouter: + """Build recovery endpoints over narrow application services.""" + if not isinstance(recovery, RecoveryPackageService): + raise TypeError("recovery must be a RecoveryPackageService") + if not isinstance(health, SystemHealthService): + raise TypeError("health must be a SystemHealthService") + if not callable(engineer_dependency) or not callable(admin_dependency): + raise TypeError("system authorization dependencies must be callable") + router = APIRouter(prefix="/api/system", tags=["system-health"]) + + @router.get("/identity") + async def identity() -> DeploymentIdentity: + return health.identity() + + @router.get("/health") + async def report() -> SystemHealthReport: + return health.report() + + @router.post("/backups") + async def backup( + _principal: Principal = Depends(admin_dependency), # noqa: B008 + ) -> Response: + try: + artifact = recovery.create() + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return Response( + content=artifact.payload, + media_type="application/zip", + headers={ + "Content-Disposition": ( + "attachment; filename=p1am-configuration-recovery.zip" + ), + "X-Artifact-SHA256": artifact.sha256, + "X-Configuration-Revision": artifact.manifest.configuration_revision, + "X-Energized-State-Included": "false", + }, + ) + + @router.post("/restores") + async def restore( + request: Request, + principal: Principal = Depends(engineer_dependency), # noqa: B008 + artifact_sha256: str | None = Header(default=None, alias="X-Artifact-SHA256"), + change_reason: str = Header(alias="X-Change-Reason"), + ) -> ConfigurationRevision: + payload = await request.body() + try: + return recovery.restore_as_draft( + payload, + principal, + change_reason, + artifact_sha256, + ) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return router diff --git a/src/p1am_control_system/backend/tests/_route_inventory.py b/src/p1am_control_system/backend/tests/_route_inventory.py new file mode 100644 index 0000000000..9623e9702b --- /dev/null +++ b/src/p1am_control_system/backend/tests/_route_inventory.py @@ -0,0 +1,86 @@ +"""Version-agnostic HTTP route inventory for a FastAPI app under test. + +Why this exists +--------------- +Tests that assert on which routes an app serves used to iterate ``app.routes`` +and read ``route.path``. That stopped being reliable: + +* Up to roughly FastAPI 0.130 / Starlette 0.52, ``include_router()`` flattened + the included ``APIRoute`` objects straight into ``app.routes``, so every entry + had a ``.path``. +* From FastAPI 0.141 / Starlette 1.6, ``include_router()`` instead leaves a + single ``fastapi.routing._IncludedRouter`` marker in ``app.routes``. That + marker has ``path=None``, exposes **no** ``.routes`` attribute, and keeps the + real routes on a private ``original_router`` with the prefix held separately + on a private ``include_context``. So the included paths are not reachable by + walking ``app.routes`` at all, recursively or otherwise. + +Both failure modes are bad, and the second is worse than it looks: + +* Reading ``route.path`` unguarded raises + ``AttributeError: '_IncludedRouter' object has no attribute 'path'``. +* Skipping entries without a string ``path`` — the obvious "tolerate it" fix — + silently yields an **empty** inventory. Any ``all(...)`` assertion over that + inventory then passes vacuously, so a safety contract like "no advisory route + exposes a command or write path" would report green while verifying nothing. + +The fix is to stop introspecting the route table and ask the app for its own +schema instead. ``app.openapi()`` resolves included routers and their prefixes +itself and returns fully-qualified, templated paths. It is public API and gives +byte-identical results on both the old and new versions, so it needs no version +branch. + +Deliberate scope: only schema-visible HTTP operations are reported. Routes +registered with ``include_in_schema=False``, and the docs/openapi endpoints +FastAPI mounts for itself, are intentionally absent — assertions here are about +the application's contract surface. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from fastapi import FastAPI + +__all__ = ["HTTP_METHODS", "methods_by_path", "route_paths"] + +# The operation keys OpenAPI defines on a Path Item Object. Everything else a +# path item may carry (``parameters``, ``summary``, ``description``, ``servers``) +# is metadata, not an operation, and must not be mistaken for a method. +HTTP_METHODS = frozenset( + {"get", "put", "post", "delete", "options", "head", "patch", "trace"} +) + + +def methods_by_path(app: FastAPI) -> dict[str, set[str]]: + """Map each schema-visible path to the upper-case HTTP methods it serves. + + Args: + app: The application to inventory. + + Returns: + ``{"/api/auth/session": {"POST", "DELETE"}, ...}``. Paths keep OpenAPI + templating, so a parameterised route appears as ``/api/x/{tag}/shelf``. + Paths whose path item declares no operation are omitted. + """ + inventory: dict[str, set[str]] = {} + for path, item in (app.openapi().get("paths") or {}).items(): + methods = {key.upper() for key in item if key.lower() in HTTP_METHODS} + if methods: + inventory.setdefault(path, set()).update(methods) + return inventory + + +def route_paths(app: FastAPI) -> set[str]: + """Return every schema-visible path the app serves. + + Args: + app: The application to inventory. + + Returns: + The set of templated paths. Callers asserting a *negative* property over + this set (for example "no path contains 'write'") should also assert the + set is non-empty, or the assertion cannot fail. + """ + return set(methods_by_path(app)) diff --git a/src/p1am_control_system/backend/tests/test_advisory_router.py b/src/p1am_control_system/backend/tests/test_advisory_router.py new file mode 100644 index 0000000000..7307903516 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_advisory_router.py @@ -0,0 +1,67 @@ +"""REST tests for advisory review without an authoritative write path.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from _route_inventory import route_paths +from advisory_router import create_advisory_router +from advisory_workspace import AdvisoryService +from fastapi import FastAPI +from fastapi.testclient import TestClient +from identity import Principal, Role + + +def _client() -> tuple[TestClient, FastAPI]: + app = FastAPI() + service = AdvisoryService(now=lambda: datetime(2026, 8, 3, 21, 0, tzinfo=UTC)) + app.include_router( + create_advisory_router( + service, + operator_dependency=lambda: Principal( + "operator.one", "Operator One", Role.OPERATOR + ), + ) + ) + return TestClient(app), app + + +def test_representative_advisory_and_disposition_are_review_only() -> None: + client, app = _client() + + response = client.get("/api/operator/advisories/representative") + assert response.status_code == 200 + advisory = response.json() + assert advisory["authoritative_write_available"] is False + assert advisory["replay"]["verified"] is True + + disposition = client.post( + f"/api/operator/advisories/{advisory['advisory_id']}/dispositions", + json={"decision": "accepted_for_review", "reason": "Use in synthetic study"}, + ) + assert disposition.status_code == 200 + assert disposition.json()["applied_to_control"] is False + + advisory_paths = {path for path in route_paths(app) if "/advisories" in path} + # Guard the guard: an empty set would satisfy the `all(...)` below vacuously, + # which is exactly what happened when this inventory was built by walking + # `app.routes` and skipping FastAPI's `_IncludedRouter` marker. See + # _route_inventory for why the schema is the authority here. + assert advisory_paths, "advisory routes not discovered; next check is vacuous" + assert all("command" not in path and "write" not in path for path in advisory_paths) + + +def test_unknown_advisory_cannot_receive_a_disposition() -> None: + client, _ = _client() + + response = client.post( + "/api/operator/advisories/unknown/dispositions", + json={"decision": "rejected", "reason": "No matching result"}, + ) + + assert response.status_code == 404 diff --git a/src/p1am_control_system/backend/tests/test_advisory_workspace.py b/src/p1am_control_system/backend/tests/test_advisory_workspace.py new file mode 100644 index 0000000000..03d3b3ddfd --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_advisory_workspace.py @@ -0,0 +1,97 @@ +"""Contracts for the synthetic, non-authoritative advisory workspace.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from advisory_workspace import ( + AdvisoryDisposition, + AdvisoryRequest, + AdvisoryService, + ConstraintEnvelope, + DispositionDecision, +) +from identity import Principal, Role + +NOW = datetime(2026, 8, 3, 21, 0, tzinfo=UTC) + + +def _request() -> AdvisoryRequest: + return AdvisoryRequest( + dataset_id="SYNTHETIC.DATASET.RUN-042", + observed_throughput=62.0, + observed_energy=47.0, + requested_throughput=68.0, + ) + + +def test_evaluation_is_reproducible_and_carries_complete_evidence() -> None: + service = AdvisoryService(now=lambda: NOW) + + first = service.evaluate(_request()) + replay = service.evaluate(_request()) + + assert replay == first + assert first.model.model_id == "SYNTHETIC.MODEL.ADVISORY" + assert first.model.version == "1.0.0" + assert len(first.model.artifact_sha256) == 64 + assert first.data.dataset_id == "SYNTHETIC.DATASET.RUN-042" + assert len(first.data.content_sha256) == 64 + assert ( + first.constraints.minimum + <= first.recommended_setpoint + <= first.constraints.maximum + ) + assert first.confidence.lower <= first.confidence.estimate <= first.confidence.upper + assert first.replay.verified is True + assert len(first.replay.input_sha256) == 64 + assert len(first.replay.result_sha256) == 64 + assert first.authoritative_write_available is False + assert first.data_classification == "synthetic" + assert first.not_for_live_control is True + + +def test_constraints_and_confidence_reject_invalid_ranges() -> None: + with pytest.raises(ValueError, match="minimum"): + ConstraintEnvelope(minimum=80.0, maximum=70.0, unit="synthetic unit") + + with pytest.raises(ValueError, match="finite"): + AdvisoryRequest( + dataset_id="SYNTHETIC.DATASET.INVALID", + observed_throughput=float("nan"), + observed_energy=1.0, + requested_throughput=2.0, + ) + + +def test_operator_disposition_is_attributable_and_cannot_apply_control() -> None: + service = AdvisoryService(now=lambda: NOW) + result = service.evaluate(_request()) + principal = Principal("operator.one", "Operator One", Role.OPERATOR) + + disposition = service.record_disposition( + result.advisory_id, + AdvisoryDisposition( + decision=DispositionDecision.DEFERRED, + reason="Review with the next synthetic operating scenario", + ), + principal, + ) + + assert disposition.actor == "operator.one" + assert disposition.advisory_id == result.advisory_id + assert disposition.applied_to_control is False + assert service.dispositions(result.advisory_id) == (disposition,) + assert service.result(result.advisory_id) == result + + with pytest.raises(ValueError, match="reason"): + AdvisoryDisposition( + decision=DispositionDecision.REJECTED, + reason=" ", + ) diff --git a/src/p1am_control_system/backend/tests/test_alarm_lifecycle.py b/src/p1am_control_system/backend/tests/test_alarm_lifecycle.py new file mode 100644 index 0000000000..c7505d5a57 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_alarm_lifecycle.py @@ -0,0 +1,132 @@ +"""Deterministic professional alarm lifecycle and performance contracts.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from alarm_lifecycle import ( # noqa: E402 + AlarmDefinition, + AlarmLifecycle, + AlarmManager, + AlarmPriority, +) +from identity import Principal, Role # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + +NOW = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) +OPERATOR = Principal("operator.1", "Operator One", Role.OPERATOR) + + +def _definition(tag: str = "TAG_0") -> AlarmDefinition: + return AlarmDefinition( + tag=tag, + low_limit=10.0, + high_limit=90.0, + priority=AlarmPriority.HIGH, + deadband=2.0, + on_delay=timedelta(seconds=2), + off_delay=timedelta(seconds=1), + help_text="Check the synthetic source and upstream permissive.", + suppression_rules=frozenset({"synthetic.maintenance"}), + ) + + +def test_alarm_activation_and_return_honor_delay_and_deadband() -> None: + manager = AlarmManager([_definition()]) + + assert manager.evaluate("TAG_0", 95.0, NOW).lifecycle is AlarmLifecycle.INACTIVE + active = manager.evaluate("TAG_0", 95.0, NOW + timedelta(seconds=2)) + assert active.lifecycle is AlarmLifecycle.UNACKNOWLEDGED + assert active.condition == "high" + assert active.first_out_sequence == 1 + + # Inside the high-side deadband: remain active and do not start return delay. + assert ( + manager.evaluate("TAG_0", 89.0, NOW + timedelta(seconds=3)).condition == "high" + ) + manager.evaluate("TAG_0", 87.0, NOW + timedelta(seconds=4)) + returned = manager.evaluate("TAG_0", 87.0, NOW + timedelta(seconds=5)) + assert returned.lifecycle is AlarmLifecycle.RETURNED_UNACKNOWLEDGED + + +def test_acknowledged_alarm_clears_only_after_return() -> None: + manager = AlarmManager([_definition()]) + manager.evaluate("TAG_0", 95.0, NOW) + manager.evaluate("TAG_0", 95.0, NOW + timedelta(seconds=2)) + + acknowledged = manager.acknowledge("TAG_0", OPERATOR, NOW + timedelta(seconds=3)) + assert acknowledged.lifecycle is AlarmLifecycle.ACKNOWLEDGED + manager.evaluate("TAG_0", 50.0, NOW + timedelta(seconds=4)) + assert ( + manager.evaluate("TAG_0", 50.0, NOW + timedelta(seconds=5)).lifecycle + is AlarmLifecycle.INACTIVE + ) + + +def test_authorized_shelving_requires_reason_and_expires() -> None: + manager = AlarmManager([_definition()]) + manager.evaluate("TAG_0", 95.0, NOW) + manager.evaluate("TAG_0", 95.0, NOW + timedelta(seconds=2)) + + shelved = manager.shelve( + "TAG_0", + OPERATOR, + reason="Synthetic maintenance", + until=NOW + timedelta(minutes=10), + now=NOW + timedelta(seconds=3), + ) + assert shelved.lifecycle is AlarmLifecycle.SHELVED + assert ( + manager.snapshot("TAG_0", NOW + timedelta(minutes=11)).lifecycle + is AlarmLifecycle.UNACKNOWLEDGED + ) + with pytest.raises(ValueError, match="reason"): + manager.shelve("TAG_0", OPERATOR, "", NOW + timedelta(minutes=1), NOW) + + manager.shelve( + "TAG_0", + OPERATOR, + "Short check", + NOW + timedelta(minutes=20), + NOW + timedelta(minutes=11), + ) + assert ( + manager.unshelve("TAG_0", OPERATOR, NOW + timedelta(minutes=12)).lifecycle + is AlarmLifecycle.UNACKNOWLEDGED + ) + + +def test_only_designed_suppression_rules_can_hide_alarm() -> None: + manager = AlarmManager([_definition()]) + with pytest.raises(ValueError, match="not designed"): + manager.set_suppression("TAG_0", "ad-hoc", True, NOW) + + suppressed = manager.set_suppression("TAG_0", "synthetic.maintenance", True, NOW) + assert suppressed.lifecycle is AlarmLifecycle.SUPPRESSED + assert suppressed.suppression_rule == "synthetic.maintenance" + + +def test_first_out_order_help_and_performance_report_are_deterministic() -> None: + manager = AlarmManager([_definition("TAG_0"), _definition("TAG_1")]) + for tag, offset in (("TAG_1", 0), ("TAG_0", 1)): + manager.evaluate(tag, 95.0, NOW + timedelta(seconds=offset)) + manager.evaluate(tag, 95.0, NOW + timedelta(seconds=offset + 2)) + manager.acknowledge("TAG_1", OPERATOR, NOW + timedelta(seconds=5)) + + snapshots = manager.active_snapshots(NOW + timedelta(seconds=5)) + assert [item.tag for item in snapshots] == ["TAG_1", "TAG_0"] + assert snapshots[0].help_text.startswith("Check the synthetic") + report = manager.performance_report() + assert report.activations == 2 + assert report.acknowledged_activations == 1 + assert report.mean_acknowledgement_seconds == pytest.approx(3.0) diff --git a/src/p1am_control_system/backend/tests/test_alarm_router.py b/src/p1am_control_system/backend/tests/test_alarm_router.py new file mode 100644 index 0000000000..14857c0bf4 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_alarm_router.py @@ -0,0 +1,104 @@ +"""API contracts for the supervisory professional alarm workspace.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from alarm_lifecycle import AlarmDefinition, AlarmManager, AlarmPriority # noqa: E402 +from alarm_router import create_alarm_router # noqa: E402 +from alarm_service import AlarmService # noqa: E402 +from identity import Principal, Role # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + +OPERATOR = Principal("operator.1", "Operator One", Role.OPERATOR) +ENGINEER = Principal("engineer.1", "Engineer One", Role.ENGINEER) + + +def _client() -> tuple[TestClient, AlarmService]: + manager = AlarmManager( + [ + AlarmDefinition( + tag="TAG_0", + low_limit=10, + high_limit=90, + priority=AlarmPriority.HIGH, + deadband=1, + on_delay=timedelta(0), + off_delay=timedelta(0), + help_text="Synthetic alarm response guidance.", + suppression_rules=frozenset({"synthetic.maintenance"}), + ) + ] + ) + service = AlarmService(manager) + service.observe({"TAG_0": 95}, datetime(2026, 8, 3, tzinfo=UTC)) + app = FastAPI() + app.include_router( + create_alarm_router( + service, + operator_dependency=lambda: OPERATOR, + engineer_dependency=lambda: ENGINEER, + ) + ) + return TestClient(app), service + + +def test_active_alarm_surface_includes_lifecycle_help_and_first_out() -> None: + client, _service = _client() + + response = client.get("/api/alarm-management/active") + + assert response.status_code == 200 + alarm = response.json()[0] + assert alarm["lifecycle"] == "unacknowledged" + assert alarm["priority"] == "high" + assert alarm["first_out_sequence"] == 1 + assert alarm["help_text"].startswith("Synthetic") + + +def test_acknowledge_and_timed_shelving_mutations() -> None: + client, _service = _client() + + shelf = client.post( + "/api/alarm-management/TAG_0/shelf", + json={"reason": "Synthetic maintenance", "duration_seconds": 300}, + ) + assert shelf.status_code == 200 + assert shelf.json()["lifecycle"] == "shelved" + assert client.delete("/api/alarm-management/TAG_0/shelf").status_code == 200 + acknowledged = client.post("/api/alarm-management/TAG_0/acknowledge") + assert acknowledged.status_code == 200 + assert acknowledged.json()["acknowledged_by"] == "operator.1" + + +def test_designed_suppression_and_performance_report() -> None: + client, _service = _client() + + response = client.post( + "/api/alarm-management/TAG_0/suppression", + json={"rule": "synthetic.maintenance", "active": True}, + ) + assert response.status_code == 200 + assert response.json()["lifecycle"] == "suppressed" + report = client.get("/api/alarm-management/performance") + assert report.status_code == 200 + assert report.json()["activations"] == 1 + + +def test_unknown_alarm_is_a_bounded_not_found_contract() -> None: + client, _service = _client() + + response = client.post("/api/alarm-management/UNKNOWN/acknowledge") + + assert response.status_code == 404 diff --git a/src/p1am_control_system/backend/tests/test_alarm_service.py b/src/p1am_control_system/backend/tests/test_alarm_service.py new file mode 100644 index 0000000000..2bf29e7747 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_alarm_service.py @@ -0,0 +1,56 @@ +"""Application-service contracts for generic routing-to-alarm adaptation.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from alarm_service import AlarmService, manager_from_routing # noqa: E402 +from models import InterlockConfig, RoutingConfig # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _routing() -> RoutingConfig: + return RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=90, + hihi_limit=100, + ) + }, + ) + + +def test_routing_adapter_builds_generic_nonconfidential_alarm_definition() -> None: + now = datetime(2026, 8, 3, tzinfo=UTC) + service = AlarmService(manager_from_routing(_routing()), clock=lambda: now) + + service.observe({"TAG_0": 95}, now) + assert service.active() == [] # representative one-second on-delay + service.observe({"TAG_0": 95}, now + timedelta(seconds=1)) + + active = service.active()[0] + assert active.tag == "TAG_0" + assert active.help_text == "Review signal quality and the generic process context." + + +def test_routing_adapter_rejects_invalid_limit_order() -> None: + invalid = _routing() + invalid.interlocks["TAG_0"].high_limit = 5 + + with pytest.raises(ValueError, match="ordered"): + manager_from_routing(invalid) diff --git a/src/p1am_control_system/backend/tests/test_asset_health.py b/src/p1am_control_system/backend/tests/test_asset_health.py new file mode 100644 index 0000000000..50a394391f --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_asset_health.py @@ -0,0 +1,131 @@ +"""F10 contracts for maintainable asset-health advisories.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from asset_health import ( + AdvisoryCode, + AssetHealthPolicy, + AssetHealthService, + AssetObservation, +) + + +def _observation( + at: datetime, + value: float, + *, + reference: float = 10.0, + command: bool = True, + feedback: bool = True, + running: bool = True, +) -> AssetObservation: + return AssetObservation( + observed_at=at, + value=value, + reference=reference, + command=command, + feedback=feedback, + running=running, + ) + + +def test_report_detects_calibration_drift_flatline_and_mismatch_as_advisories() -> None: + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + observations = tuple( + _observation(start + timedelta(seconds=index * 10), 15.0, feedback=False) + for index in range(7) + ) + service = AssetHealthService( + AssetHealthPolicy( + drift_limit=2.0, + flatline_duration=timedelta(seconds=30), + flatline_span=0.01, + mismatch_duration=timedelta(seconds=30), + noise_standard_deviation=3.0, + ), + now=lambda: start + timedelta(minutes=2), + ) + + report = service.assess( + "SYNTHETIC.FEED.PUMP", + observations, + calibration_due_at=start - timedelta(days=1), + ) + + assert {advisory.code for advisory in report.advisories} == { + AdvisoryCode.CALIBRATION_DUE, + AdvisoryCode.DRIFT, + AdvisoryCode.FLATLINE, + AdvisoryCode.COMMAND_FEEDBACK_MISMATCH, + } + assert all( + advisory.classification == "maintenance_advisory" + for advisory in report.advisories + ) + assert all(advisory.authoritative_trip is False for advisory in report.advisories) + assert report.counters.runtime_seconds == 60 + assert report.counters.start_count == 1 + assert report.statistics.sample_count == 7 + + +def test_noisy_signal_and_device_statistics_are_reproducible() -> None: + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + values = (0.0, 20.0, 0.0, 20.0, 0.0) + observations = tuple( + _observation(start + timedelta(seconds=index), value, reference=value) + for index, value in enumerate(values) + ) + service = AssetHealthService( + AssetHealthPolicy( + drift_limit=2.0, + flatline_duration=timedelta(seconds=30), + flatline_span=0.01, + mismatch_duration=timedelta(seconds=30), + noise_standard_deviation=5.0, + ), + now=lambda: start + timedelta(seconds=5), + ) + + report = service.assess( + "SYNTHETIC.REACTOR.TEMPERATURE", + observations, + calibration_due_at=start + timedelta(days=1), + ) + + assert [advisory.code for advisory in report.advisories] == [ + AdvisoryCode.NOISY_SIGNAL + ] + assert report.statistics.minimum == 0 + assert report.statistics.maximum == 20 + assert report.statistics.mean == 8 + assert report.statistics.standard_deviation > 5 + + +def test_start_counter_distinguishes_transitions_from_runtime() -> None: + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + observations = ( + _observation(start, 10, running=False), + _observation(start + timedelta(seconds=10), 10, running=True), + _observation(start + timedelta(seconds=20), 10, running=True), + _observation(start + timedelta(seconds=30), 10, running=False), + _observation(start + timedelta(seconds=40), 10, running=True), + ) + service = AssetHealthService( + AssetHealthPolicy(), now=lambda: start + timedelta(seconds=40) + ) + + report = service.assess( + "SYNTHETIC.FEED.PUMP", + observations, + calibration_due_at=start + timedelta(days=1), + ) + + assert report.counters.start_count == 2 + assert report.counters.runtime_seconds == 20 diff --git a/src/p1am_control_system/backend/tests/test_audit_log.py b/src/p1am_control_system/backend/tests/test_audit_log.py new file mode 100644 index 0000000000..0a3455fddf --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_audit_log.py @@ -0,0 +1,140 @@ +"""Contract and persistence tests for the append-only SCADA audit trail.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from sqlalchemy import text +from sqlmodel import Session, SQLModel, create_engine, select + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from audit_log import ( # noqa: E402 + AuditEvent, + AuditLog, + AuditOutcome, + append_audit_event, + install_append_only_guards, +) +from identity import Principal, Role # noqa: E402 + + +@pytest.fixture +def audit_engine(tmp_path: Path): + engine = create_engine(f"sqlite:///{tmp_path / 'audit.db'}") + SQLModel.metadata.create_all(engine) + install_append_only_guards(engine) + return engine + + +def _event(**overrides: object) -> AuditEvent: + values: dict[str, object] = { + "principal": Principal( + subject="engineer.one", + display_name="Engineer One", + role=Role.ENGINEER, + ), + "action": "configuration.update", + "target": "routing/default", + "reason": "approved synthetic test", + "outcome": AuditOutcome.SUCCEEDED, + "before": {"limit": 10.0}, + "after": {"limit": 12.0}, + "source": "test-client", + "configuration_revision": "cfg-0001", + "correlation_id": "request-0001", + } + values.update(overrides) + return AuditEvent(**values) # type: ignore[arg-type] + + +def test_audit_event_rejects_missing_reason_and_identity() -> None: + with pytest.raises(ValueError, match="reason"): + _event(reason=" ") + with pytest.raises(TypeError, match="principal"): + _event(principal=None) + + +def test_append_audit_event_persists_attribution_and_redacts_secrets( + audit_engine, +) -> None: + event = _event( + before={ + "setpoint": 10.0, + "api_key": "must-not-persist", # pragma: allowlist secret + "nested": {"authorization": "Bearer must-not-persist"}, + }, + after={"setpoint": 12.0, "session_token": "must-not-persist"}, + ) + + with Session(audit_engine) as session: + row = append_audit_event(session, event) + session.commit() + stored = session.exec(select(AuditLog)).one() + + assert row.id is not None + assert stored.actor_subject == "engineer.one" + assert stored.actor_role == "engineer" + assert stored.action == "configuration.update" + assert stored.outcome == "succeeded" + assert stored.reason == "approved synthetic test" + assert stored.configuration_revision == "cfg-0001" + assert stored.correlation_id == "request-0001" + assert json.loads(stored.before_json) == { + "setpoint": 10.0, + "api_key": "[REDACTED]", + "nested": {"authorization": "[REDACTED]"}, + } + assert json.loads(stored.after_json) == { + "setpoint": 12.0, + "session_token": "[REDACTED]", + } + assert "must-not-persist" not in stored.before_json + assert "must-not-persist" not in stored.after_json + + +def test_append_audit_event_preserves_failed_attempt() -> None: + event = _event( + outcome=AuditOutcome.FAILED, + error_code="permission_denied", + after=None, + ) + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + stored = append_audit_event(session, event) + session.commit() + session.refresh(stored) + assert stored.outcome == "failed" + assert stored.error_code == "permission_denied" + assert stored.after_json == "null" + + +def test_database_guards_reject_audit_update_and_delete(audit_engine) -> None: + with Session(audit_engine) as session: + row = append_audit_event(session, _event()) + session.commit() + row_id = row.id + + with audit_engine.begin() as connection: + with pytest.raises(Exception, match="append-only"): + connection.execute( + text("UPDATE auditlog SET reason='changed' WHERE id=:row_id"), + {"row_id": row_id}, + ) + + with audit_engine.begin() as connection: + with pytest.raises(Exception, match="append-only"): + connection.execute( + text("DELETE FROM auditlog WHERE id=:row_id"), + {"row_id": row_id}, + ) + + +def test_append_audit_event_requires_session() -> None: + with pytest.raises(TypeError, match="session"): + append_audit_event(object(), _event()) # type: ignore[arg-type] diff --git a/src/p1am_control_system/backend/tests/test_audit_middleware.py b/src/p1am_control_system/backend/tests/test_audit_middleware.py new file mode 100644 index 0000000000..349cfa2df2 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_audit_middleware.py @@ -0,0 +1,108 @@ +"""End-to-end contracts for automatic mutation-attempt auditing.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from audit_log import AuditLog, install_append_only_guards # noqa: E402 +from audit_middleware import MutationAuditMiddleware # noqa: E402 +from identity import Principal, Role # noqa: E402 + + +@pytest.fixture +def audited_app() -> Iterator[tuple[TestClient, object]]: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + install_append_only_guards(engine) + app = FastAPI() + principal = Principal("operator.1", "Operator One", Role.OPERATOR) + app.add_middleware( + MutationAuditMiddleware, + engine=engine, + principal_resolver=lambda _request: principal, + configuration_revision=lambda: "config-42", + ) + + @app.post("/api/setpoint") + async def setpoint(payload: dict[str, object]) -> dict[str, object]: + return payload + + @app.delete("/api/protected") + async def denied() -> None: + raise HTTPException(status_code=403, detail="denied") + + @app.patch("/api/broken") + async def broken() -> None: + raise RuntimeError("controller failed") + + @app.get("/api/status") + async def status() -> dict[str, str]: + return {"status": "ok"} + + with TestClient(app, raise_server_exceptions=False) as client: + yield client, engine + + +def _rows(engine: object) -> list[AuditLog]: + with Session(engine) as session: # type: ignore[arg-type] + return list(session.exec(select(AuditLog).order_by(AuditLog.id))) + + +def test_successful_mutation_is_attributed_and_secret_redacted(audited_app) -> None: + client, engine = audited_app + response = client.post( + "/api/setpoint", + json={ + "value": 12.5, + "password": "never-store-this", + }, # noqa: E501 # pragma: allowlist secret + headers={ + "X-Change-Reason": "Commissioning check", + "X-Correlation-ID": "work-order-17", + }, + ) + + assert response.status_code == 200 + row = _rows(engine)[0] + assert row.actor_subject == "operator.1" + assert row.reason == "Commissioning check" + assert row.configuration_revision == "config-42" + assert row.correlation_id == "work-order-17" + assert row.outcome == "succeeded" + payload = json.loads(row.after_json) + assert payload["request"]["password"] == "[REDACTED]" + assert "never-store-this" not in row.after_json + + +def test_denied_and_runtime_failed_mutations_are_both_audited(audited_app) -> None: + client, engine = audited_app + + assert client.delete("/api/protected").status_code == 403 + assert client.patch("/api/broken").status_code == 500 + + rows = _rows(engine) + assert [row.outcome for row in rows] == ["failed", "failed"] + assert [row.error_code for row in rows] == ["HTTP_403", "EXCEPTION"] + + +def test_read_only_request_is_not_written_to_mutation_audit(audited_app) -> None: + client, engine = audited_app + + assert client.get("/api/status").status_code == 200 + + assert _rows(engine) == [] diff --git a/src/p1am_control_system/backend/tests/test_audit_router.py b/src/p1am_control_system/backend/tests/test_audit_router.py new file mode 100644 index 0000000000..d2a26c765d --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_audit_router.py @@ -0,0 +1,89 @@ +"""Read-side API contracts for the immutable audit trail.""" + +from __future__ import annotations + +import sys +from collections.abc import Generator +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from audit_log import AuditEvent, AuditOutcome, append_audit_event # noqa: E402 +from audit_router import create_audit_router # noqa: E402 +from identity import Principal, Role # noqa: E402 + + +def _client() -> TestClient: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + operator = Principal("operator.1", "Operator One", Role.OPERATOR) + with Session(engine) as session: + for index, outcome in enumerate( + (AuditOutcome.SUCCEEDED, AuditOutcome.FAILED), start=1 + ): + append_audit_event( + session, + AuditEvent( + principal=operator, + action="post /api/setpoint", + target="/api/setpoint", + reason=f"test {index}", + outcome=outcome, + before={}, + after={"value": index}, + source="test", + configuration_revision="rev-1", + correlation_id=f"corr-{index}", + error_code=None if index == 1 else "HTTP_403", + ), + ) + session.commit() + + def get_session() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session + + app = FastAPI() + app.include_router(create_audit_router(get_session, lambda: operator)) + return TestClient(app) + + +def test_audit_page_is_newest_first_and_structured() -> None: + response = _client().get("/api/audit?limit=1") + + assert response.status_code == 200 + payload = response.json() + assert payload["limit"] == 1 + assert payload["offset"] == 0 + assert len(payload["items"]) == 1 + assert payload["items"][0]["correlation_id"] == "corr-2" + assert payload["items"][0]["outcome"] == "failed" + + +def test_audit_query_filters_by_actor_outcome_and_correlation() -> None: + response = _client().get( + "/api/audit", + params={ + "actor_subject": "operator.1", + "outcome": "succeeded", + "correlation_id": "corr-1", + }, + ) + + assert response.status_code == 200 + assert [item["correlation_id"] for item in response.json()["items"]] == ["corr-1"] + + +def test_audit_query_rejects_invalid_outcome_contract() -> None: + response = _client().get("/api/audit?outcome=maybe") + + assert response.status_code == 422 diff --git a/src/p1am_control_system/backend/tests/test_auth_config.py b/src/p1am_control_system/backend/tests/test_auth_config.py index 355aa3ce00..578c7b2c58 100644 --- a/src/p1am_control_system/backend/tests/test_auth_config.py +++ b/src/p1am_control_system/backend/tests/test_auth_config.py @@ -29,11 +29,16 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from auth_config import ( # noqa: E402 + identity_service, require_admin_key, require_api_key, + require_engineer_key, + resolve_optional_principal, verify_operator_key, ) from fastapi import HTTPException, status # noqa: E402 +from fastapi.security import HTTPAuthorizationCredentials # noqa: E402 +from identity import Principal, Role # noqa: E402 _OPERATOR_KEY = "operator-secret" # pragma: allowlist secret _ADMIN_KEY = "admin-secret" # pragma: allowlist secret @@ -84,7 +89,8 @@ def test_require_api_key_passes_with_correct_operator_key( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) - assert require_api_key(api_key=_OPERATOR_KEY) is None + principal = require_api_key(api_key=_OPERATOR_KEY, bearer=None) + assert principal == Principal("legacy.single-key", "Legacy User", Role.ADMIN) def test_require_api_key_accepts_admin_key_as_operator( @@ -93,7 +99,7 @@ def test_require_api_key_accepts_admin_key_as_operator( monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) monkeypatch.setenv("P1AM_ADMIN_API_KEY", _ADMIN_KEY) # The admin key is also accepted for plain operator-gated routes. - assert require_api_key(api_key=_ADMIN_KEY) is None + assert require_api_key(api_key=_ADMIN_KEY, bearer=None).role is Role.ADMIN def test_require_api_key_dev_no_auth_bypasses( @@ -101,7 +107,7 @@ def test_require_api_key_dev_no_auth_bypasses( ) -> None: monkeypatch.setenv("P1AM_DEV_NO_AUTH", "1") # No key configured and no key supplied, yet the bypass lets it through. - assert require_api_key(api_key=None) is None + assert require_api_key(api_key=None, bearer=None).role is Role.ADMIN def test_require_api_key_dev_no_auth_wins_over_missing_key( @@ -109,7 +115,7 @@ def test_require_api_key_dev_no_auth_wins_over_missing_key( ) -> None: monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) monkeypatch.setenv("P1AM_DEV_NO_AUTH", "1") - assert require_api_key(api_key=None) is None + assert require_api_key(api_key=None, bearer=None).role is Role.ADMIN # --------------------------------------------------------------------------- # @@ -148,7 +154,7 @@ def test_require_admin_key_passes_with_correct_admin_key( ) -> None: monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) monkeypatch.setenv("P1AM_ADMIN_API_KEY", _ADMIN_KEY) - assert require_admin_key(api_key=_ADMIN_KEY) is None + assert require_admin_key(api_key=_ADMIN_KEY, bearer=None).role is Role.ADMIN def test_require_admin_key_accepts_operator_key_when_no_admin_set( @@ -156,7 +162,7 @@ def test_require_admin_key_accepts_operator_key_when_no_admin_set( ) -> None: # Single-key deployment: no admin key -> operator key is accepted. monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) - assert require_admin_key(api_key=_OPERATOR_KEY) is None + assert require_admin_key(api_key=_OPERATOR_KEY, bearer=None).role is Role.ADMIN def test_require_admin_key_401_with_wrong_key_when_no_admin_set( @@ -173,7 +179,77 @@ def test_require_admin_key_dev_no_auth_bypasses( ) -> None: monkeypatch.setenv("P1AM_ADMIN_API_KEY", _ADMIN_KEY) monkeypatch.setenv("P1AM_DEV_NO_AUTH", "1") - assert require_admin_key(api_key=None) is None + assert require_admin_key(api_key=None, bearer=None).role is Role.ADMIN + + +def test_named_engineer_can_operate_but_cannot_admin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "P1AM_PRINCIPALS_JSON", + '[{"subject":"eng.1","display_name":"Engineer One",' + '"role":"engineer","api_key":"engineer-key-12345"}]', # noqa: E501 # pragma: allowlist secret + ) + principal = require_api_key(api_key="engineer-key-12345", bearer=None) + assert principal.subject == "eng.1" + with pytest.raises(HTTPException) as excinfo: + require_admin_key(api_key="engineer-key-12345", bearer=None) # noqa: E501 # pragma: allowlist secret + assert excinfo.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_engineer_gate_rejects_named_operator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "P1AM_PRINCIPALS_JSON", + '[{"subject":"op.1","display_name":"Operator One",' + '"role":"operator","api_key":"operator-key-12345"}]', # noqa: E501 # pragma: allowlist secret + ) + + with pytest.raises(HTTPException) as excinfo: + require_engineer_key(api_key="operator-key-12345", bearer=None) # noqa: E501 # pragma: allowlist secret + + assert excinfo.value.status_code == status.HTTP_403_FORBIDDEN + + +def test_operator_gate_accepts_short_lived_bearer_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv( + "P1AM_PRINCIPALS_JSON", + '[{"subject":"op.1","display_name":"Operator One",' + '"role":"operator","api_key":"operator-key-12345"}]', # noqa: E501 # pragma: allowlist secret + ) + service = identity_service() + assert service is not None + issued = service.login("operator-key-12345") + assert issued is not None + bearer = HTTPAuthorizationCredentials(scheme="Bearer", credentials=issued.token) + + principal = require_api_key(api_key=None, bearer=bearer) + assert principal.subject == "op.1" + + +def test_invalid_bearer_does_not_fall_back_to_valid_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) + bearer = HTTPAuthorizationCredentials(scheme="Bearer", credentials="invalid") + with pytest.raises(HTTPException) as excinfo: + require_api_key(api_key=_OPERATOR_KEY, bearer=bearer) + assert excinfo.value.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_optional_principal_resolver_supports_audit_attribution( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("P1AM_API_KEY", _OPERATOR_KEY) + + principal = resolve_optional_principal(_OPERATOR_KEY, None) + + assert principal is not None + assert principal.subject == "legacy.single-key" + assert resolve_optional_principal("invalid", None) is None # --------------------------------------------------------------------------- # diff --git a/src/p1am_control_system/backend/tests/test_availability.py b/src/p1am_control_system/backend/tests/test_availability.py new file mode 100644 index 0000000000..5e37b29208 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_availability.py @@ -0,0 +1,78 @@ +"""F15 command authority, ordered buffering, and safe fault behavior.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from availability import AvailabilityPolicy, AvailabilityService, BufferedSample + + +def _service() -> AvailabilityService: + return AvailabilityService( + AvailabilityPolicy( + recovery_time_objective=timedelta(minutes=5), + recovery_point_objective=timedelta(seconds=30), + max_clock_skew=timedelta(seconds=2), + buffer_capacity=10, + ) + ) + + +def test_exactly_one_command_authority_is_enforced() -> None: + service = _service() + + lease = service.acquire_authority("SYNTHETIC.CONTROLLER.PRIMARY") + + with pytest.raises(PermissionError, match="already held"): + service.acquire_authority("SYNTHETIC.CONTROLLER.SECONDARY") + assert service.authority == lease + + +def test_offline_buffer_reconciles_ordered_unique_samples() -> None: + service = _service() + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + service.set_transport_available(False) + service.ingest(BufferedSample(sequence=1, timestamp=start, value=10)) + service.ingest( + BufferedSample(sequence=2, timestamp=start + timedelta(seconds=1), value=11) + ) + + with pytest.raises(ValueError, match="strictly increase"): + service.ingest(BufferedSample(sequence=3, timestamp=start, value=12)) + + service.set_transport_available(True) + reconciled = service.reconcile() + + assert [sample.sequence for sample in reconciled] == [1, 2] + assert service.reconcile() == [] + + +def test_hmi_loss_rejects_energizing_but_allows_deenergizing_command() -> None: + service = _service() + service.acquire_authority("SYNTHETIC.CONTROLLER.PRIMARY") + service.inject_fault("hmi_unavailable") + + energize = service.command("SYNTHETIC.HEATER.ENABLE", energizing=True) + deenergize = service.command("SYNTHETIC.HEATER.ENABLE", energizing=False) + + assert energize.accepted is False + assert energize.fail_closed is True + assert deenergize.accepted is True + + +def test_health_report_exposes_recovery_and_clock_contracts() -> None: + service = _service() + service.report_clock_skew(timedelta(seconds=3)) + + health = service.health() + + assert health.recovery_time_objective_seconds == 300 + assert health.recovery_point_objective_seconds == 30 + assert health.clock_ordering_reliable is False + assert health.command_authority is None diff --git a/src/p1am_control_system/backend/tests/test_backend.py b/src/p1am_control_system/backend/tests/test_backend.py index 7fb8cb088c..171439851f 100644 --- a/src/p1am_control_system/backend/tests/test_backend.py +++ b/src/p1am_control_system/backend/tests/test_backend.py @@ -21,6 +21,7 @@ pytest.importorskip("httpx") pytest.importorskip("fastapi.testclient") +import main as main_module from fastapi.testclient import TestClient from main import app, control_context, get_session, modbus_manager from models import InterlockConfig, PIDConfig, RoutingConfig, TagLog @@ -196,7 +197,7 @@ async def test_get_routing_success() -> None: async def test_update_routing_success( sample_routing_config: RoutingConfig, ) -> None: - """Verify POST /api/routing writes configs and triggers Save to Flash coil.""" + """Verify the retired direct route cannot bypass protected activation.""" mock_write_routing = AsyncMock(return_value=True) mock_save_flash = AsyncMock(return_value=True) @@ -207,10 +208,26 @@ async def test_update_routing_success( ): payload = sample_routing_config.model_dump() response = client.post("/api/routing", json=payload) - assert response.status_code == 200 - assert response.json()["status"] == "success" - mock_write_routing.assert_called_once() - mock_save_flash.assert_called_once() + assert response.status_code == 409 + assert "protected" in response.json()["detail"] + mock_write_routing.assert_not_called() + mock_save_flash.assert_not_called() + + +@pytest.mark.asyncio +async def test_failed_approved_deployment_never_publishes_runtime_config( + sample_routing_config: RoutingConfig, +) -> None: + publish = MagicMock() + with ( + patch.object(modbus_manager, "_connected", True), + patch.object(modbus_manager, "write_routing", AsyncMock(return_value=False)), + patch.object(main_module, "_apply_control_config", publish), + ): + with pytest.raises(RuntimeError, match="rejected"): + await main_module._deploy_approved_routing(sample_routing_config) + + publish.assert_not_called() @pytest.mark.asyncio diff --git a/src/p1am_control_system/backend/tests/test_configuration_repository.py b/src/p1am_control_system/backend/tests/test_configuration_repository.py new file mode 100644 index 0000000000..0564d5f8e3 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_configuration_repository.py @@ -0,0 +1,97 @@ +"""SQLite persistence contracts for configuration revision identity.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_repository import SqliteRevisionRepository # noqa: E402 +from configuration_workflow import ( # noqa: E402 + ConfigurationRevision, + ConfigurationState, +) +from models import InterlockConfig, RoutingConfig # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _revision( + revision_id: str = "cfg-000001-aaaaaaaaaaaa", + state: ConfigurationState = ConfigurationState.DRAFT, +) -> ConfigurationRevision: + payload = RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=90, + hihi_limit=100, + ) + }, + ) + return ConfigurationRevision( + revision_id=revision_id, + version=int(revision_id[4:10]), + state=state, + payload=payload, + payload_sha256="a" * 64, + reason="Synthetic test revision", + created_by="engineer", + created_at=datetime(2026, 8, 3, tzinfo=UTC), + ) + + +@pytest.fixture +def repository() -> SqliteRevisionRepository: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return SqliteRevisionRepository(lambda: Session(engine)) + + +def test_repository_round_trips_revision_and_monotonic_version(repository) -> None: + repository.save(_revision()) + + restored = repository.get("cfg-000001-aaaaaaaaaaaa") + assert restored.payload.interlocks["TAG_0"].high_limit == 90 + assert restored.state is ConfigurationState.DRAFT + assert repository.next_version() == 2 + + +def test_repository_rejects_payload_rewrite_under_existing_identity(repository) -> None: + original = _revision() + repository.save(original) + changed_payload = original.payload.model_copy(deep=True) + changed_payload.interlocks["TAG_0"].high_limit = 80 + rewritten = original.model_copy(update={"payload": changed_payload}) + + with pytest.raises(ValueError, match="immutable"): + repository.save(rewritten) + + +def test_activation_supersedes_prior_revision_atomically(repository) -> None: + first = _revision(state=ConfigurationState.ACTIVE) + second = _revision("cfg-000002-bbbbbbbbbbbb", ConfigurationState.APPROVED) + repository.save(first) + repository.save(second) + + repository.activate(second.model_copy(update={"state": ConfigurationState.ACTIVE})) + + assert repository.get(first.revision_id).state is ConfigurationState.SUPERSEDED + assert repository.get(second.revision_id).state is ConfigurationState.ACTIVE diff --git a/src/p1am_control_system/backend/tests/test_configuration_router.py b/src/p1am_control_system/backend/tests/test_configuration_router.py new file mode 100644 index 0000000000..289cdda2e6 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_configuration_router.py @@ -0,0 +1,115 @@ +"""REST contracts for the protected configuration workflow.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_router import create_configuration_router # noqa: E402 +from configuration_workflow import ( # noqa: E402 + ConfigurationWorkflow, + InMemoryRevisionRepository, +) +from identity import Principal, Role # noqa: E402 +from models import InterlockConfig, RoutingConfig # noqa: E402 + + +def _routing() -> RoutingConfig: + return RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=90, + hihi_limit=100, + ) + }, + ) + + +def _client() -> tuple[TestClient, list[RoutingConfig]]: + deployed: list[RoutingConfig] = [] + + async def deploy(config: RoutingConfig) -> None: + deployed.append(config) + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + engineer = Principal("engineer", "Engineer", Role.ENGINEER) + admin = Principal("admin", "Admin", Role.ADMIN) + app = FastAPI() + app.include_router( + create_configuration_router( + workflow, + engineer_dependency=lambda: engineer, + admin_dependency=lambda: admin, + ) + ) + return TestClient(app), deployed + + +def test_api_exposes_reviewed_activation_and_machine_readable_diff() -> None: + client, deployed = _client() + created = client.post( + "/api/configurations/drafts", + json={"payload": _routing().model_dump(), "reason": "Synthetic change"}, + ) + assert created.status_code == 200 + revision_id = created.json()["revision_id"] + + assert client.post(f"/api/configurations/{revision_id}/validate").status_code == 200 + diff = client.get(f"/api/configurations/{revision_id}/diff") + assert diff.status_code == 200 + assert diff.json() + assert client.post(f"/api/configurations/{revision_id}/review").status_code == 200 + approved = client.post( + f"/api/configurations/{revision_id}/approve", + json={"reason": "Synthetic review complete"}, + ) + assert approved.json()["state"] == "approved" + activated = client.post(f"/api/configurations/{revision_id}/activate") + + assert activated.status_code == 200 + assert activated.json()["state"] == "active" + assert activated.json()["activation_identity"] == revision_id + assert len(deployed) == 1 + + +def test_api_rejects_silent_direct_activation_and_bounds_unknown_ids() -> None: + client, deployed = _client() + response = client.post("/api/configurations/unknown/activate") + + assert response.status_code == 404 + assert deployed == [] + + +def test_api_rollback_creates_new_revision_identity() -> None: + client, _deployed = _client() + created = client.post( + "/api/configurations/drafts", + json={"payload": _routing().model_dump(), "reason": "Synthetic baseline"}, + ).json() + revision_id = created["revision_id"] + client.post(f"/api/configurations/{revision_id}/validate") + client.post(f"/api/configurations/{revision_id}/review") + client.post( + f"/api/configurations/{revision_id}/approve", + json={"reason": "Synthetic approval"}, + ) + client.post(f"/api/configurations/{revision_id}/activate") + + rollback = client.post( + f"/api/configurations/{revision_id}/rollback", + json={"reason": "Synthetic recovery exercise"}, + ) + + assert rollback.status_code == 200 + assert rollback.json()["source_revision_id"] == revision_id + assert rollback.json()["revision_id"] != revision_id diff --git a/src/p1am_control_system/backend/tests/test_configuration_workflow.py b/src/p1am_control_system/backend/tests/test_configuration_workflow.py new file mode 100644 index 0000000000..b7b6092e1b --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_configuration_workflow.py @@ -0,0 +1,144 @@ +"""Contracts for protected, immutable configuration revision workflows.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_workflow import ( # noqa: E402 + ConfigurationState, + ConfigurationWorkflow, + InMemoryRevisionRepository, +) +from identity import Principal, Role # noqa: E402 +from models import InterlockConfig, RoutingConfig # noqa: E402 + + +def _principal(subject: str, role: Role = Role.ENGINEER) -> Principal: + return Principal(subject=subject, display_name=subject.title(), role=role) + + +def _routing(high: float = 90) -> RoutingConfig: + return RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=high, + hihi_limit=100, + ) + }, + ) + + +async def _approved_revision( + workflow: ConfigurationWorkflow, + high: float = 90, +): + author = _principal("author") + reviewer = _principal("reviewer") + draft = workflow.create_draft(_routing(high), author, "Synthetic test change") + validated = workflow.validate(draft.revision_id, author) + in_review = workflow.submit_for_review(validated.revision_id, author) + return workflow.approve(in_review.revision_id, reviewer, "Reviewed synthetic diff") + + +@pytest.mark.asyncio +async def test_protected_revision_requires_every_transition_before_activation() -> None: + deployed: list[RoutingConfig] = [] + + async def deploy(config: RoutingConfig) -> None: + deployed.append(config) + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + draft = workflow.create_draft( + _routing(), _principal("author"), "Synthetic test change" + ) + + with pytest.raises(ValueError, match="approved"): + await workflow.activate(draft.revision_id, _principal("admin", Role.ADMIN)) + + approved = await _approved_revision(workflow) + active = await workflow.activate( + approved.revision_id, _principal("admin", Role.ADMIN) + ) + + assert active.state is ConfigurationState.ACTIVE + assert active.activated_by == "admin" + assert active.activation_identity == approved.revision_id + assert active.activation_identity.startswith("cfg-") + assert deployed == [_routing()] + + +@pytest.mark.asyncio +async def test_failed_deployment_does_not_claim_an_active_revision() -> None: + async def fail_deploy(_config: RoutingConfig) -> None: + raise RuntimeError("synthetic adapter refused deployment") + + repository = InMemoryRevisionRepository() + workflow = ConfigurationWorkflow(repository, fail_deploy) + approved = await _approved_revision(workflow) + + with pytest.raises(RuntimeError, match="refused"): + await workflow.activate(approved.revision_id, _principal("admin", Role.ADMIN)) + + assert workflow.get(approved.revision_id).state is ConfigurationState.APPROVED + assert workflow.active() is None + + +@pytest.mark.asyncio +async def test_rollback_clones_history_into_a_new_identified_revision() -> None: + deployed: list[RoutingConfig] = [] + + async def deploy(config: RoutingConfig) -> None: + deployed.append(config) + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + first = await _approved_revision(workflow, high=80) + first_active = await workflow.activate( + first.revision_id, _principal("admin", Role.ADMIN) + ) + second = await _approved_revision(workflow, high=90) + await workflow.activate(second.revision_id, _principal("admin", Role.ADMIN)) + + rollback = await workflow.rollback( + first_active.revision_id, + _principal("admin", Role.ADMIN), + "Synthetic recovery exercise", + ) + + assert rollback.state is ConfigurationState.ACTIVE + assert rollback.revision_id not in {first.revision_id, second.revision_id} + assert rollback.source_revision_id == first.revision_id + assert rollback.payload == first.payload + assert len(deployed) == 3 + + +def test_validation_and_diff_are_semantic_and_machine_readable() -> None: + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), lambda _config: None) + baseline = workflow.create_draft( + _routing(80), _principal("author"), "Synthetic baseline" + ) + workflow.validate(baseline.revision_id, _principal("author")) + changed = workflow.create_draft( + _routing(90), _principal("author"), "Synthetic setpoint change" + ) + + diff = workflow.diff(changed.revision_id, baseline.revision_id) + assert any( + item.path == "interlocks.TAG_0.high_limit" + and item.before == 80 + and item.after == 90 + for item in diff + ) + + changed.payload.interlocks["TAG_0"].high_limit = 5 + with pytest.raises(ValueError, match="ordered"): + workflow.validate(changed.revision_id, _principal("author")) diff --git a/src/p1am_control_system/backend/tests/test_connector_plugins.py b/src/p1am_control_system/backend/tests/test_connector_plugins.py new file mode 100644 index 0000000000..b5726b59ad --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_connector_plugins.py @@ -0,0 +1,88 @@ +"""F11 isolated connector/plugin and diagnostic contracts.""" + +from __future__ import annotations + +from connector_plugins import ( + CommandDisposition, + ConnectorDescriptor, + ConnectorManager, + ConnectorSample, +) + + +class HealthyConnector: + descriptor = ConnectorDescriptor( + connector_id="SYNTHETIC.CONNECTOR.HEALTHY", + version="1.0.0", + tags=("SYNTHETIC.HEALTHY.PV",), + writable_tags=("SYNTHETIC.HEALTHY.SP",), + ) + + def read(self) -> dict[str, float]: + return {"SYNTHETIC.HEALTHY.PV": 42.0} + + def write(self, tag: str, value: float) -> None: + assert tag == "SYNTHETIC.HEALTHY.SP" + assert value == 10 + + def diagnostics(self) -> dict[str, object]: + return {"endpoint": "synthetic://healthy", "api_token": "do-not-expose"} + + +class FailedConnector: + descriptor = ConnectorDescriptor( + connector_id="SYNTHETIC.CONNECTOR.FAILED", + version="1.0.0", + tags=("SYNTHETIC.FAILED.PV",), + writable_tags=("SYNTHETIC.FAILED.SP",), + ) + + def read(self) -> dict[str, float]: + raise ConnectionError("secret=field-password") # pragma: allowlist secret + + def write(self, tag: str, value: float) -> None: + raise ConnectionError("token=field-token") + + def diagnostics(self) -> dict[str, object]: + return {"password": "field-password", "state": "offline"} + + +def test_failed_connector_degrades_only_its_tags_without_crashing_poll() -> None: + manager = ConnectorManager((HealthyConnector(), FailedConnector())) + + samples = manager.poll() + + assert samples["SYNTHETIC.HEALTHY.PV"] == ConnectorSample( + value=42.0, + quality="good", + diagnostic="", + connector_id="SYNTHETIC.CONNECTOR.HEALTHY", + ) + assert samples["SYNTHETIC.FAILED.PV"].value is None + assert samples["SYNTHETIC.FAILED.PV"].quality == "bad" + assert "SYNTHETIC.CONNECTOR.FAILED" in samples["SYNTHETIC.FAILED.PV"].diagnostic + assert "field-password" not in samples["SYNTHETIC.FAILED.PV"].diagnostic + + +def test_failed_and_unknown_commands_fail_closed() -> None: + manager = ConnectorManager((HealthyConnector(), FailedConnector())) + + accepted = manager.command("SYNTHETIC.HEALTHY.SP", 10) + failed = manager.command("SYNTHETIC.FAILED.SP", 10) + unknown = manager.command("SYNTHETIC.UNKNOWN.SP", 10) + + assert accepted.disposition is CommandDisposition.ACCEPTED + assert failed.disposition is CommandDisposition.REJECTED + assert unknown.disposition is CommandDisposition.REJECTED + assert failed.fail_closed is True + assert "field-token" not in failed.diagnostic + + +def test_diagnostics_identify_connector_and_redact_secrets() -> None: + manager = ConnectorManager((HealthyConnector(), FailedConnector())) + + diagnostics = manager.diagnostics() + + assert diagnostics[0].connector_id == "SYNTHETIC.CONNECTOR.HEALTHY" + assert diagnostics[0].details["api_token"] == "[REDACTED]" + assert diagnostics[1].details["password"] == "[REDACTED]" diff --git a/src/p1am_control_system/backend/tests/test_database.py b/src/p1am_control_system/backend/tests/test_database.py index 84110375fc..ecec6e545f 100644 --- a/src/p1am_control_system/backend/tests/test_database.py +++ b/src/p1am_control_system/backend/tests/test_database.py @@ -15,6 +15,7 @@ pytest.importorskip("sqlmodel") import database # noqa: E402 +from audit_log import AuditLog # noqa: E402,F401 (registers audit metadata) from models import TagLog # noqa: E402,F401 (registers the table in metadata) from sqlalchemy import text # noqa: E402 from sqlmodel import Session, SQLModel, create_engine # noqa: E402 @@ -103,6 +104,92 @@ def test_migration_creates_composite_and_drops_single(tmp_path) -> None: assert "ix_taglog_tag_name" not in names +def test_init_db_installs_append_only_audit_guards(tmp_path, monkeypatch) -> None: + engine = create_engine(f"sqlite:///{tmp_path / 'init-audit.db'}") + monkeypatch.setattr(database, "engine", engine) + + database.init_db() + + with engine.connect() as connection: + trigger_names = { + row[0] + for row in connection.execute( + text( + "SELECT name FROM sqlite_master WHERE type='trigger' " + "AND tbl_name='auditlog'" + ) + ) + } + assert trigger_names == {"auditlog_no_delete", "auditlog_no_update"} + + +def test_init_db_creates_versioned_configuration_store_idempotently( + tmp_path, monkeypatch +) -> None: + engine = create_engine(f"sqlite:///{tmp_path / 'init-configuration.db'}") + monkeypatch.setattr(database, "engine", engine) + + database.init_db() + database.init_db() + + with engine.connect() as connection: + table = connection.execute( + text( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name='configurationrevisionrecord'" + ) + ).scalar_one() + assert table == "configurationrevisionrecord" + + +def test_historian_quality_migration_preserves_legacy_rows( + tmp_path, monkeypatch +) -> None: + engine = create_engine(f"sqlite:///{tmp_path / 'legacy-quality.db'}") + with engine.begin() as connection: + connection.execute( + text( + "CREATE TABLE taglog (id INTEGER PRIMARY KEY, tag_name VARCHAR " + "NOT NULL, value FLOAT NOT NULL, timestamp DATETIME NOT NULL)" + ) + ) + connection.execute( + text( + "INSERT INTO taglog(tag_name, value, timestamp) " + "VALUES ('TAG_0', 1.5, '2026-08-03 12:00:00')" + ) + ) + monkeypatch.setattr(database, "engine", engine) + + database._migrate_historian_quality_columns() + database._migrate_historian_quality_columns() + + with engine.connect() as connection: + columns = { + row[1] for row in connection.exec_driver_sql("PRAGMA table_info(taglog)") + } + row = connection.execute( + text( + "SELECT quality, diagnostic_reason, sequence, source, " + "source_timestamp FROM taglog" + ) + ).one() + assert { + "quality", + "diagnostic_reason", + "sequence", + "source", + "source_timestamp", + } <= columns + assert tuple(row) == ( + "uncertain", + "legacy_unqualified", + 0, + "legacy.adapter", + "2026-08-03 12:00:00", + ) + + def test_trend_query_uses_composite_index(tmp_path) -> None: # The composite index must actually serve the trend query plan (no temp sort). engine = create_engine(f"sqlite:///{tmp_path / 'plan.db'}") diff --git a/src/p1am_control_system/backend/tests/test_historian.py b/src/p1am_control_system/backend/tests/test_historian.py index 93ca489ebf..c173bb346e 100644 --- a/src/p1am_control_system/backend/tests/test_historian.py +++ b/src/p1am_control_system/backend/tests/test_historian.py @@ -25,6 +25,7 @@ from historian import log_scan # noqa: E402 from models import TagLog # noqa: E402 +from signal_quality import SignalFrameFactory # noqa: E402 from sqlalchemy import StaticPool, func # noqa: E402 from sqlmodel import Session, SQLModel, col, create_engine, select # noqa: E402 @@ -61,6 +62,34 @@ def test_shared_timestamp(self, session: Session) -> None: stamps = {r.timestamp for r in session.exec(select(TagLog)).all()} assert len(stamps) == 1 # every row shares the one scan timestamp + def test_signal_provenance_persists_with_each_historian_value( + self, session: Session + ) -> None: + frame = SignalFrameFactory( + clock=lambda: datetime(2026, 1, 1, tzinfo=UTC) + ).stale({"TAG_0": 4.5}, source="synthetic.driver", reason="read_timeout") + + log_scan(session, frame.values, signal_frame=frame) + session.commit() + + row = session.exec(select(TagLog)).one() + assert row.quality == "stale" + assert row.diagnostic_reason == "read_timeout" + assert row.source == "synthetic.driver" + assert row.sequence == 1 + assert row.source_timestamp == frame.samples["TAG_0"].source_timestamp.replace( + tzinfo=None + ) + assert row.timestamp == frame.server_timestamp.replace(tzinfo=None) + + def test_signal_frame_must_match_logged_tag_contract( + self, session: Session + ) -> None: + frame = SignalFrameFactory().good({"TAG_0": 1.0}) + + with pytest.raises(ValueError, match="must match"): + log_scan(session, {"TAG_0": 2.0}, signal_frame=frame) + def test_empty_scan_writes_nothing(self, session: Session) -> None: assert log_scan(session, {}) == 0 session.commit() diff --git a/src/p1am_control_system/backend/tests/test_historian_shipper.py b/src/p1am_control_system/backend/tests/test_historian_shipper.py new file mode 100644 index 0000000000..86e565e2b4 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_historian_shipper.py @@ -0,0 +1,335 @@ +"""Unit tests for the store-and-forward shipper. + +The properties under test are safety properties, not performance ones: the +producer side must never block, never raise, and never grow without bound, no +matter what the remote destination does. +""" + +from __future__ import annotations + +import sys +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from historian_shipper import ( # noqa: E402 + RemoteHistorianWriter, + Sample, + StoreAndForwardSink, +) +from historian_sink import HistorianSink # noqa: E402 + +pytestmark = pytest.mark.unit + +_TS = datetime(2026, 7, 31, 12, 0, 0, tzinfo=UTC) + +# Bound every wait so a regression that reintroduces blocking fails fast rather +# than hanging the suite. +_WAIT_TIMEOUT_S = 5.0 + + +class _FakeRemote: + """A cooperative remote writer with controllable failure modes.""" + + def __init__( + self, + *, + fail_connect: bool = False, + fail_write: bool = False, + block_write: threading.Event | None = None, + ) -> None: + self.fail_connect = fail_connect + self.fail_write = fail_write + self.block_write = block_write + self.batches: list[list[Sample]] = [] + self.connects = 0 + self.closes = 0 + self._lock = threading.Lock() + self.wrote = threading.Event() + + def connect(self) -> None: + with self._lock: + self.connects += 1 + if self.fail_connect: + raise ConnectionRefusedError("historian down") + + def write_batch(self, samples: Any) -> int: + if self.block_write is not None: + self.block_write.wait(_WAIT_TIMEOUT_S) + if self.fail_write: + raise RuntimeError("write failed") + with self._lock: + self.batches.append(list(samples)) + self.wrote.set() + return len(samples) + + def close(self) -> None: + with self._lock: + self.closes += 1 + + def total_written(self) -> int: + with self._lock: + return sum(len(b) for b in self.batches) + + +def _no_jitter() -> float: + return 0.0 + + +def _wait_for(predicate: Any, timeout_s: float = _WAIT_TIMEOUT_S) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return False + + +# ------------------------------------------------------------------ contract --- + + +def test_shipper_satisfies_the_sink_protocol() -> None: + sink = StoreAndForwardSink(_FakeRemote()) + assert isinstance(sink, HistorianSink) + + +def test_fake_remote_satisfies_the_remote_protocol() -> None: + assert isinstance(_FakeRemote(), RemoteHistorianWriter) + + +# ----------------------------------------------------------------- happy path --- + + +def test_samples_reach_the_remote() -> None: + remote = _FakeRemote() + sink = StoreAndForwardSink(remote, batch_size=10, flush_interval_s=0.05) + sink.start() + try: + assert sink.write_scan({"TAG_0": 1.0, "TAG_1": 2.0}, _TS) == 2 + assert _wait_for(lambda: remote.total_written() == 2) + finally: + sink.close(timeout_s=2.0) + + flat = [s for batch in remote.batches for s in batch] + assert sorted(s[1] for s in flat) == ["TAG_0", "TAG_1"] + assert all(s[0] == _TS for s in flat) + + +def test_non_numeric_values_are_skipped_not_fatal() -> None: + """The local historian already rejects these loudly; forwarding just skips.""" + sink = StoreAndForwardSink(_FakeRemote()) + tags: Any = {"TAG_0": 1.0, "TAG_1": "oops"} + assert sink.write_scan(tags, _TS) == 1 + + +# ------------------------------------------------------- producer never blocks --- + + +def test_enqueue_does_not_block_when_the_remote_hangs() -> None: + """The property that protects the 10 Hz scan loop.""" + blocker = threading.Event() + remote = _FakeRemote(block_write=blocker) + sink = StoreAndForwardSink(remote, queue_max=50, flush_interval_s=0.01) + sink.start() + try: + start = time.monotonic() + for _ in range(200): + sink.write_scan({"TAG_0": 1.0}, _TS) + elapsed = time.monotonic() - start + # 200 enqueues against a wedged remote. Generous bound — the point is + # that this is not gated on the blocked writer at all. + assert elapsed < 1.0, f"enqueue blocked for {elapsed:.3f}s" + finally: + blocker.set() + sink.close(timeout_s=2.0) + + +def test_enqueue_never_raises_when_the_remote_is_dead() -> None: + sink = StoreAndForwardSink(_FakeRemote(fail_connect=True), queue_max=10) + sink.start() + try: + for _ in range(100): + sink.write_scan({"TAG_0": 1.0}, _TS) + finally: + sink.close(timeout_s=2.0) + + +def test_writes_work_before_start_is_called() -> None: + """Ordering must not matter; a scan before the worker starts is not an error.""" + sink = StoreAndForwardSink(_FakeRemote(), queue_max=10) + assert sink.write_scan({"TAG_0": 1.0}, _TS) == 1 + + +# ---------------------------------------------------------------- boundedness --- + + +def test_queue_is_bounded_and_drops_oldest() -> None: + """An unbounded queue on the control Pi is an OOM crash of the controller.""" + sink = StoreAndForwardSink(_FakeRemote(fail_connect=True), queue_max=10) + # Not started: nothing drains, so overflow is deterministic. + for i in range(100): + sink.write_scan({f"TAG_{i}": float(i)}, _TS) + + stats = sink.stats() + assert stats.queue_depth <= 10 + assert stats.dropped_total >= 90 + + +def test_drop_counter_is_accurate() -> None: + sink = StoreAndForwardSink(_FakeRemote(fail_connect=True), queue_max=5) + for i in range(25): + sink.write_scan({f"TAG_{i}": 1.0}, _TS) + + stats = sink.stats() + assert stats.queue_depth + stats.dropped_total == 25 + + +# -------------------------------------------------------------------- failure --- + + +def test_reconnects_after_the_remote_recovers() -> None: + remote = _FakeRemote(fail_connect=True) + sink = StoreAndForwardSink(remote, flush_interval_s=0.01, jitter=_no_jitter) + sink.start() + try: + assert _wait_for(lambda: remote.connects >= 1) + assert not sink.stats().connected + + remote.fail_connect = False + assert _wait_for(lambda: sink.stats().connected) + + sink.write_scan({"TAG_0": 42.0}, _TS) + assert _wait_for(lambda: remote.total_written() >= 1) + finally: + sink.close(timeout_s=2.0) + + +def test_write_failure_marks_disconnected_and_counts_drops() -> None: + remote = _FakeRemote(fail_write=True) + sink = StoreAndForwardSink( + remote, batch_size=5, flush_interval_s=0.01, jitter=_no_jitter + ) + sink.start() + try: + for _ in range(5): + sink.write_scan({"TAG_0": 1.0}, _TS) + assert _wait_for(lambda: sink.stats().dropped_total > 0) + assert _wait_for(lambda: remote.closes >= 1) + finally: + sink.close(timeout_s=2.0) + + +def test_stats_report_lag_after_a_success() -> None: + remote = _FakeRemote() + sink = StoreAndForwardSink(remote, flush_interval_s=0.01) + sink.start() + try: + sink.write_scan({"TAG_0": 1.0}, _TS) + assert _wait_for(lambda: sink.stats().last_success_ts is not None) + stats = sink.stats() + assert stats.lag_s is not None + assert stats.lag_s >= 0.0 + assert stats.shipped_total >= 1 + finally: + sink.close(timeout_s=2.0) + + +def test_stats_before_any_activity_are_a_clean_zero() -> None: + stats = StoreAndForwardSink(_FakeRemote(), queue_max=7).stats() + assert stats.enabled is True + assert stats.connected is False + assert stats.queue_depth == 0 + assert stats.queue_max == 7 + assert stats.shipped_total == 0 + assert stats.dropped_total == 0 + assert stats.last_success_ts is None + assert stats.lag_s is None + + +def test_stats_as_dict_is_json_serialisable() -> None: + import json + + payload = StoreAndForwardSink(_FakeRemote()).stats().as_dict() + json.loads(json.dumps(payload)) + assert payload["enabled"] is True + + +# ------------------------------------------------------------------- shutdown --- + + +def test_close_is_bounded_when_the_remote_hangs() -> None: + """Shutdown must not hang on an unreachable historian.""" + blocker = threading.Event() + remote = _FakeRemote(block_write=blocker) + sink = StoreAndForwardSink(remote, flush_interval_s=0.01) + sink.start() + try: + sink.write_scan({"TAG_0": 1.0}, _TS) + time.sleep(0.1) + start = time.monotonic() + sink.close(timeout_s=0.5) + elapsed = time.monotonic() - start + assert elapsed < 3.0, f"close took {elapsed:.2f}s" + finally: + blocker.set() + + +def test_close_is_idempotent() -> None: + sink = StoreAndForwardSink(_FakeRemote()) + sink.start() + sink.close(timeout_s=1.0) + sink.close(timeout_s=1.0) + + +def test_start_is_idempotent() -> None: + sink = StoreAndForwardSink(_FakeRemote()) + sink.start() + sink.start() + try: + assert _wait_for(lambda: sink.stats().connected) + finally: + sink.close(timeout_s=2.0) + + +# ------------------------------------------------------------------------ DbC --- + + +def test_rejects_a_writer_that_is_not_a_remote_writer() -> None: + with pytest.raises(TypeError, match="writer must implement"): + bad: Any = object() + StoreAndForwardSink(bad) + + +@pytest.mark.parametrize("bad", [0, -1]) +def test_rejects_non_positive_queue_max(bad: int) -> None: + with pytest.raises(ValueError, match="queue_max must be >= 1"): + StoreAndForwardSink(_FakeRemote(), queue_max=bad) + + +@pytest.mark.parametrize("bad", [0, -5]) +def test_rejects_non_positive_batch_size(bad: int) -> None: + with pytest.raises(ValueError, match="batch_size must be >= 1"): + StoreAndForwardSink(_FakeRemote(), batch_size=bad) + + +def test_rejects_non_int_queue_max() -> None: + with pytest.raises(TypeError, match="queue_max must be an int"): + bad: Any = 1.5 + StoreAndForwardSink(_FakeRemote(), queue_max=bad) + + +@pytest.mark.parametrize("bad", [0.0, -1.0, float("inf"), float("nan")]) +def test_rejects_bad_flush_interval(bad: float) -> None: + with pytest.raises(ValueError, match="flush_interval_s"): + StoreAndForwardSink(_FakeRemote(), flush_interval_s=bad) diff --git a/src/p1am_control_system/backend/tests/test_historian_sink.py b/src/p1am_control_system/backend/tests/test_historian_sink.py new file mode 100644 index 0000000000..421c279f7a --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_historian_sink.py @@ -0,0 +1,324 @@ +"""Unit tests for the historian sink seam. + +Covers the forwarding contract that protects the control path: a broken remote +historian must not reduce local durability, must not raise into the scan loop, +and must not change what the throttle decides. +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +# Backend deps (sqlmodel/sqlalchemy) aren't installed in the shared CI `tests` +# job, so skip this module there rather than erroring on collection. +pytest.importorskip("sqlmodel") + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from historian_sink import ( # noqa: E402 + HistorianSink, + HistorianWriter, + NullHistorianSink, +) + +pytestmark = pytest.mark.unit + +_TS = datetime(2026, 7, 31, 12, 0, 0, tzinfo=UTC) + + +class _RecordingSink: + """Captures forwarded scans.""" + + def __init__(self) -> None: + self.scans: list[tuple[dict[str, float], datetime]] = [] + self.closed = False + + def write_scan(self, tags: Any, timestamp: datetime) -> int: + self.scans.append((dict(tags), timestamp)) + return len(tags) + + def close(self) -> None: + self.closed = True + + +class _ExplodingSink: + """Fails every way a remote historian can fail.""" + + def __init__(self, exc: Exception | None = None) -> None: + self.exc = exc or RuntimeError("historian unreachable") + self.calls = 0 + + def write_scan(self, tags: Any, timestamp: datetime) -> int: + self.calls += 1 + raise self.exc + + def close(self) -> None: + raise self.exc + + +def _always_due() -> bool: + return True + + +def _never_due() -> bool: + return False + + +def _fake_log_scan(recorder: list[Any]) -> Any: + def _inner( + session: Any, + tags: dict[str, float], + *, + timestamp: Any = None, + signal_frame: Any = None, + ) -> int: + recorder.append((session, dict(tags), timestamp)) + return len(tags) + + return _inner + + +# --------------------------------------------------------------- NullSink --- + + +def test_null_sink_satisfies_the_protocol() -> None: + assert isinstance(NullHistorianSink(), HistorianSink) + + +def test_null_sink_accepts_and_discards() -> None: + sink = NullHistorianSink() + assert sink.write_scan({"TAG_0": 1.0}, _TS) == 0 + assert sink.close() is None + + +# ----------------------------------------------------------------- writer --- + + +def test_writer_persists_locally_and_forwards_the_same_timestamp() -> None: + """A sample must be correlatable across the two stores exactly.""" + calls: list[Any] = [] + sink = _RecordingSink() + writer = HistorianWriter( + due=_always_due, + sink=sink, + log_scan=_fake_log_scan(calls), + clock=lambda: _TS, + ) + + written = writer.write(object(), {"TAG_0": 1.5, "TAG_1": 2.5}) + + assert written == 2 + assert len(calls) == 1 + _, local_tags, local_ts = calls[0] + assert local_tags == {"TAG_0": 1.5, "TAG_1": 2.5} + assert local_ts == _TS + assert sink.scans == [({"TAG_0": 1.5, "TAG_1": 2.5}, _TS)] + + +def test_writer_skips_both_stores_when_throttle_declines() -> None: + """Local and remote stay in lockstep so the two stores stay comparable.""" + calls: list[Any] = [] + sink = _RecordingSink() + writer = HistorianWriter( + due=_never_due, + sink=sink, + log_scan=_fake_log_scan(calls), + clock=lambda: _TS, + ) + + assert writer.write(object(), {"TAG_0": 1.0}) == 0 + assert calls == [] + assert sink.scans == [] + + +def test_writer_consults_the_throttle_exactly_once_per_scan() -> None: + """`due` consumes the throttle window; calling it twice would double-consume.""" + hits = 0 + + def counting_due() -> bool: + nonlocal hits + hits += 1 + return True + + writer = HistorianWriter( + due=counting_due, + sink=_RecordingSink(), + log_scan=_fake_log_scan([]), + clock=lambda: _TS, + ) + writer.write(object(), {"TAG_0": 1.0}) + + assert hits == 1 + + +def test_remote_failure_does_not_reach_the_scan_loop() -> None: + """The whole point of the seam: a dead historian cannot fault a scan.""" + calls: list[Any] = [] + sink = _ExplodingSink() + writer = HistorianWriter( + due=_always_due, + sink=sink, + log_scan=_fake_log_scan(calls), + clock=lambda: _TS, + ) + + written = writer.write(object(), {"TAG_0": 9.0}) + + assert written == 1, "local write must still have happened" + assert len(calls) == 1 + assert sink.calls == 1 + + +@pytest.mark.parametrize( + "exc", + [ + RuntimeError("boom"), + ConnectionRefusedError("no route"), + TimeoutError("slow"), + MemoryError("driver blew up"), + ], +) +def test_any_remote_exception_type_is_contained(exc: Exception) -> None: + writer = HistorianWriter( + due=_always_due, + sink=_ExplodingSink(exc), + log_scan=_fake_log_scan([]), + clock=lambda: _TS, + ) + assert writer.write(object(), {"TAG_0": 1.0}) == 1 + + +def test_writer_returns_local_row_count_not_forwarded_count() -> None: + """Callers must not be able to confuse 'unreachable' with 'not recorded'.""" + writer = HistorianWriter( + due=_always_due, + sink=_ExplodingSink(), + log_scan=_fake_log_scan([]), + clock=lambda: _TS, + ) + assert writer.write(object(), {"TAG_0": 1.0, "TAG_1": 2.0}) == 2 + + +def test_writer_without_a_sink_still_persists_locally() -> None: + calls: list[Any] = [] + writer = HistorianWriter(due=_always_due, log_scan=_fake_log_scan(calls)) + + assert writer.write(object(), {"TAG_0": 1.0}) == 1 + assert len(calls) == 1 + assert isinstance(writer.sink, NullHistorianSink) + + +def test_writer_forwards_signal_frame_to_the_local_write() -> None: + """Signal quality must survive the historian-forwarding seam. + + Regression guard for the #4065 + #4091 consolidation: ``poll_runtime`` + invokes its injected ``ScanLogger`` with ``signal_frame=``, so a + ``HistorianWriter`` that swallowed the keyword would silently drop quality + metadata from every persisted sample (and, before the writer accepted it, + raise ``TypeError`` inside the scan loop). + """ + seen: list[Any] = [] + + def _recording_log_scan( + _session: Any, + tags: dict[str, float], + *, + timestamp: Any = None, + signal_frame: Any = None, + ) -> int: + seen.append(signal_frame) + return len(tags) + + frame = object() + writer = HistorianWriter( + due=_always_due, + sink=_RecordingSink(), + log_scan=_recording_log_scan, + clock=lambda: _TS, + ) + + assert writer.write(object(), {"TAG_0": 1.0}, signal_frame=frame) == 1 + assert seen == [frame] + + +def test_writer_defaults_signal_frame_to_none() -> None: + """Two-argument callers (the pre-#4091 shape) still work.""" + seen: list[Any] = [] + + def _recording_log_scan( + _session: Any, + tags: dict[str, float], + *, + timestamp: Any = None, + signal_frame: Any = None, + ) -> int: + seen.append(signal_frame) + return len(tags) + + writer = HistorianWriter(due=_always_due, log_scan=_recording_log_scan) + + assert writer.write(object(), {"TAG_0": 1.0}) == 1 + assert seen == [None] + + +def test_sink_never_receives_signal_frame() -> None: + """The forwarding contract stays ``{tag: value}`` + timestamp.""" + sink = _RecordingSink() + writer = HistorianWriter( + due=_always_due, + sink=sink, + log_scan=_fake_log_scan([]), + clock=lambda: _TS, + ) + + writer.write(object(), {"TAG_0": 1.0}, signal_frame=object()) + + assert sink.scans == [({"TAG_0": 1.0}, _TS)] + + +def test_close_forwards_to_the_sink() -> None: + sink = _RecordingSink() + HistorianWriter(due=_always_due, sink=sink).close() + assert sink.closed is True + + +def test_close_swallows_sink_failure() -> None: + """Shutdown must not fail because the historian is unreachable.""" + HistorianWriter(due=_always_due, sink=_ExplodingSink()).close() + + +# -------------------------------------------------------------------- DbC --- + + +def test_rejects_non_callable_due() -> None: + with pytest.raises(TypeError, match="due must be callable"): + bad: Any = "nope" + HistorianWriter(due=bad) + + +def test_rejects_non_callable_log_scan() -> None: + with pytest.raises(TypeError, match="log_scan must be callable"): + bad: Any = object() + HistorianWriter(due=_always_due, log_scan=bad) + + +def test_rejects_non_callable_clock() -> None: + with pytest.raises(TypeError, match="clock must be callable"): + bad: Any = 123 + HistorianWriter(due=_always_due, clock=bad) + + +def test_rejects_a_sink_that_is_not_a_sink() -> None: + with pytest.raises(TypeError, match="sink must implement HistorianSink"): + bad: Any = object() + HistorianWriter(due=_always_due, sink=bad) diff --git a/src/p1am_control_system/backend/tests/test_historian_wiring.py b/src/p1am_control_system/backend/tests/test_historian_wiring.py new file mode 100644 index 0000000000..70cc75d084 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_historian_wiring.py @@ -0,0 +1,178 @@ +"""Tests for historian wiring, settings validation, and DSN redaction. + +The recurring theme: a misconfigured plant historian must fail loudly at +startup, and an unconfigured one must cost nothing at all. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import pytest + +pytest.importorskip("sqlmodel") + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from historian_shipper import ShipperStats # noqa: E402 +from historian_sink import HistorianWriter, NullHistorianSink # noqa: E402 +from historian_wiring import build_historian_writer, shipper_stats # noqa: E402 +from settings import P1AMSettings # noqa: E402 +from timescale_writer import TimescaleWriter, redact_dsn # noqa: E402 + +pytestmark = pytest.mark.unit + + +def _always_due() -> bool: + return True + + +# ------------------------------------------------------------------ settings --- + + +def test_forwarding_is_off_by_default() -> None: + """Merging this must change nothing for an existing deployment.""" + settings = P1AMSettings(_env_file=None) + assert settings.timescale_enabled is False + assert settings.timescale_dsn == "" + + +def test_enabled_without_a_dsn_is_rejected_at_startup() -> None: + """A historian everyone believes is recording but isn't is the worst case.""" + with pytest.raises(ValueError, match="P1AM_TIMESCALE_DSN is empty"): + P1AMSettings(_env_file=None, timescale_enabled=True, timescale_dsn="") + + +def test_enabled_with_whitespace_dsn_is_rejected() -> None: + with pytest.raises(ValueError, match="P1AM_TIMESCALE_DSN is empty"): + P1AMSettings(_env_file=None, timescale_enabled=True, timescale_dsn=" ") + + +def test_enabled_with_a_dsn_is_accepted() -> None: + settings = P1AMSettings( + _env_file=None, + timescale_enabled=True, + timescale_dsn="postgresql://u:p@host/db", + ) + assert settings.timescale_enabled is True + + +def test_disabled_with_empty_dsn_is_fine() -> None: + assert P1AMSettings(_env_file=None, timescale_enabled=False).timescale_dsn == "" + + +# ------------------------------------------------------------------- wiring --- + + +def test_disabled_builds_an_inert_writer_and_no_shipper() -> None: + """Flag off must mean no thread, no queue, no driver import.""" + settings = P1AMSettings(_env_file=None, timescale_enabled=False) + writer, shipper = build_historian_writer(_always_due, settings) + + assert isinstance(writer, HistorianWriter) + assert isinstance(writer.sink, NullHistorianSink) + assert shipper is None + + +def test_disabled_wiring_does_not_import_psycopg() -> None: + """A bench Pi with no Postgres driver must still boot.""" + sys.modules.pop("psycopg", None) + settings = P1AMSettings(_env_file=None, timescale_enabled=False) + build_historian_writer(_always_due, settings) + assert "psycopg" not in sys.modules + + +def test_wiring_rejects_a_non_callable_due() -> None: + with pytest.raises(TypeError, match="due must be callable"): + bad: Any = "nope" + build_historian_writer(bad) + + +def test_stats_for_a_disabled_shipper_are_a_clean_disabled_snapshot() -> None: + """The health surface answers the same shape whether or not forwarding is on.""" + stats = shipper_stats(None) + assert isinstance(stats, ShipperStats) + assert stats.enabled is False + assert stats.connected is False + assert stats.queue_depth == 0 + assert stats.as_dict()["enabled"] is False + + +# ------------------------------------------------------------ DSN redaction --- + + +@pytest.mark.parametrize( + ("dsn", "must_not_contain"), + [ + # These DSNs carry password-shaped values on purpose: stripping them is + # the entire contract under test. The allowlist pragmas keep + # detect-secrets from treating the fixtures as leaked credentials. + # Kept short so line + pragma stays inside the 88-char limit; what is + # under test is the URI/key-value shape, not the length. + ("postgresql://u:s3cret@h:5432/db", "s3cret"), # pragma: allowlist secret + ("postgres://a:p%40ss@10.0.0.5/db", "p%40ss"), # pragma: allowlist secret + ("host=10.0.0.5 user=a password=hunter2 db=h", "hunter2"), + ("host=10.0.0.5 PASSWORD=Hunter2 db=h", "Hunter2"), + ], +) +def test_redaction_removes_the_password(dsn: str, must_not_contain: str) -> None: + redacted = redact_dsn(dsn) + assert must_not_contain not in redacted + assert "***" in redacted + + +def test_redaction_preserves_the_diagnostic_parts() -> None: + """Redaction must not destroy the host/db, or it stops being useful.""" + redacted = redact_dsn("postgresql://user:secret@plant-historian:5432/history") + assert "plant-historian" in redacted + assert "history" in redacted + assert "user" in redacted + assert "secret" not in redacted + + +def test_redaction_is_a_noop_without_a_password() -> None: + dsn = "postgresql://plant-historian:5432/history" + assert redact_dsn(dsn) == dsn + + +def test_redaction_rejects_non_strings() -> None: + with pytest.raises(TypeError, match="dsn must be a str"): + bad: Any = None + redact_dsn(bad) + + +def test_writer_exposes_only_a_redacted_dsn() -> None: + writer = TimescaleWriter("postgresql://u:topsecret@host/db") + assert "topsecret" not in writer.safe_dsn + + +# --------------------------------------------------------- TimescaleWriter DbC --- + + +def test_writer_rejects_an_empty_dsn() -> None: + with pytest.raises(ValueError, match="dsn must not be empty"): + TimescaleWriter("") + + +def test_writer_rejects_a_non_string_dsn() -> None: + with pytest.raises(TypeError, match="dsn must be a str"): + bad: Any = None + TimescaleWriter(bad) + + +@pytest.mark.parametrize("bad", [0, -1.0]) +def test_writer_rejects_non_positive_timeout(bad: float) -> None: + with pytest.raises(ValueError, match="connect_timeout_s must be positive"): + TimescaleWriter("postgresql://host/db", connect_timeout_s=bad) + + +def test_write_batch_before_connect_is_an_error() -> None: + writer = TimescaleWriter("postgresql://host/db") + with pytest.raises(RuntimeError, match="before connect"): + writer.write_batch([]) + + +def test_close_without_connect_is_a_noop() -> None: + TimescaleWriter("postgresql://host/db").close() diff --git a/src/p1am_control_system/backend/tests/test_identity.py b/src/p1am_control_system/backend/tests/test_identity.py new file mode 100644 index 0000000000..cafe98fc53 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_identity.py @@ -0,0 +1,153 @@ +"""Contract tests for named SCADA principals and short-lived sessions.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from identity import ( # noqa: E402 + CredentialRegistry, + Principal, + Role, + SessionStore, + parse_principal_config, +) + +try: + from datetime import UTC +except ImportError: # Python 3.10 support + UTC = timezone.utc # noqa: UP017 +_OPERATOR_SECRET = "operator-test-secret" # pragma: allowlist secret +_ENGINEER_SECRET = "engineer-test-secret" # pragma: allowlist secret + + +def _principal(name: str = "operator.one", role: Role = Role.OPERATOR) -> Principal: + return Principal(subject=name, display_name="Operator One", role=role) + + +def test_role_order_enforces_least_privilege() -> None: + viewer = _principal(role=Role.VIEWER) + operator = _principal(role=Role.OPERATOR) + engineer = _principal(role=Role.ENGINEER) + admin = _principal(role=Role.ADMIN) + + assert viewer.allows(Role.VIEWER) + assert not viewer.allows(Role.OPERATOR) + assert operator.allows(Role.VIEWER) + assert not operator.allows(Role.ENGINEER) + assert engineer.allows(Role.OPERATOR) + assert not engineer.allows(Role.ADMIN) + assert admin.allows(Role.ADMIN) + + +def test_principal_rejects_blank_identity() -> None: + with pytest.raises(ValueError, match="subject"): + Principal(subject=" ", display_name="Operator", role=Role.OPERATOR) + + +def test_parse_principal_config_builds_named_registry_without_secret_repr() -> None: + config = ( + '[{"subject":"operator.one","display_name":"Operator One",' + '"role":"operator","api_key":"operator-test-secret"}]' # noqa: E501 # pragma: allowlist secret + ) + + records = parse_principal_config(config) + + assert len(records) == 1 + assert records[0].principal.subject == "operator.one" + assert records[0].principal.role is Role.OPERATOR + assert _OPERATOR_SECRET not in repr(records[0]) + + +@pytest.mark.parametrize( + ("config", "error_type", "message"), + [ + ("{}", TypeError, "list"), + ("[]", ValueError, "at least one"), + ( + '[{"subject":"same","display_name":"One","role":"viewer",' + '"api_key":"a-long-enough-secret"},' # noqa: E501 # pragma: allowlist secret + '{"subject":"same","display_name":"Two","role":"operator",' + '"api_key":"another-long-secret"}]', # noqa: E501 # pragma: allowlist secret + ValueError, + "duplicate subject", + ), + ( + '[{"subject":"short","display_name":"Short","role":"viewer",' + '"api_key":"tiny"}]', # noqa: E501 # pragma: allowlist secret + ValueError, + "at least", + ), + ], +) +def test_parse_principal_config_rejects_unsafe_contracts( + config: str, error_type: type[Exception], message: str +) -> None: + with pytest.raises(error_type, match=message): + parse_principal_config(config) + + +def test_registry_authenticates_named_principal() -> None: + records = parse_principal_config( + '[{"subject":"operator.one","display_name":"Operator One",' + '"role":"operator","api_key":"operator-test-secret"},' # noqa: E501 # pragma: allowlist secret + '{"subject":"engineer.one","display_name":"Engineer One",' + '"role":"engineer","api_key":"engineer-test-secret"}]' # noqa: E501 # pragma: allowlist secret + ) + registry = CredentialRegistry(records) + + assert registry.authenticate(_OPERATOR_SECRET) == records[0].principal + assert registry.authenticate(_ENGINEER_SECRET) == records[1].principal + assert registry.authenticate("not-a-valid-secret") is None + assert registry.authenticate(None) is None + + +def test_registry_rejects_duplicate_credentials() -> None: + config = ( + '[{"subject":"operator.one","display_name":"Operator One",' + '"role":"operator","api_key":"operator-test-secret"},' # noqa: E501 # pragma: allowlist secret + '{"subject":"operator.two","display_name":"Operator Two",' + '"role":"operator","api_key":"operator-test-secret"}]' # noqa: E501 # pragma: allowlist secret + ) + with pytest.raises(ValueError, match="duplicate credential"): + CredentialRegistry(parse_principal_config(config)) + + +def test_session_store_issues_resolves_and_revokes_opaque_token() -> None: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + store = SessionStore(ttl=timedelta(minutes=15), clock=lambda: now) + principal = _principal() + + issued = store.create(principal) + + assert issued.principal == principal + assert issued.expires_at == now + timedelta(minutes=15) + assert store.resolve(issued.token) == principal + assert issued.token not in repr(store) + assert store.revoke(issued.token) + assert store.resolve(issued.token) is None + assert not store.revoke(issued.token) + + +def test_session_store_expires_session() -> None: + current = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + store = SessionStore(ttl=timedelta(seconds=30), clock=lambda: current[0]) + issued = store.create(_principal()) + + current[0] += timedelta(seconds=31) + + assert store.resolve(issued.token) is None + + +@pytest.mark.parametrize( + "ttl", + [timedelta(0), timedelta(seconds=-1), timedelta(days=2)], +) +def test_session_store_rejects_unsafe_ttl(ttl: timedelta) -> None: + with pytest.raises(ValueError, match="ttl"): + SessionStore(ttl=ttl) diff --git a/src/p1am_control_system/backend/tests/test_identity_config.py b/src/p1am_control_system/backend/tests/test_identity_config.py new file mode 100644 index 0000000000..00ca94b08d --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_identity_config.py @@ -0,0 +1,128 @@ +"""Configuration contracts for named and legacy SCADA identity services.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from fastapi.security import HTTPAuthorizationCredentials + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from identity import Role # noqa: E402 +from identity_config import ( # noqa: E402 + EnvironmentIdentityProvider, + load_identity_service, +) + +_OPERATOR_KEY = "operator-config-secret" # pragma: allowlist secret +_ADMIN_KEY = "administrator-secret" # pragma: allowlist secret + + +def test_named_principal_configuration_takes_precedence() -> None: + service = load_identity_service( + { + "P1AM_PRINCIPALS_JSON": ( + '[{"subject":"engineer.one","display_name":"Engineer One",' + '"role":"engineer","api_key":"engineer-config-secret"}]' # noqa: E501 # pragma: allowlist secret + ), + "P1AM_API_KEY": _OPERATOR_KEY, + } + ) + + assert service is not None + issued = service.login("engineer-config-secret") # pragma: allowlist secret + assert issued is not None + assert issued.principal.subject == "engineer.one" + assert issued.principal.role is Role.ENGINEER + assert service.login(_OPERATOR_KEY) is None + + +def test_distinct_legacy_keys_receive_named_operator_and_admin_roles() -> None: + service = load_identity_service( + {"P1AM_API_KEY": _OPERATOR_KEY, "P1AM_ADMIN_API_KEY": _ADMIN_KEY} + ) + + assert service is not None + operator = service.login(_OPERATOR_KEY) + admin = service.login(_ADMIN_KEY) + assert operator is not None and operator.principal.role is Role.OPERATOR + assert operator.principal.subject == "legacy.operator" + assert admin is not None and admin.principal.role is Role.ADMIN + assert admin.principal.subject == "legacy.admin" + + +def test_single_legacy_key_retains_existing_admin_capability() -> None: + service = load_identity_service({"P1AM_API_KEY": _OPERATOR_KEY}) + + assert service is not None + issued = service.login(_OPERATOR_KEY) + assert issued is not None + assert issued.principal.role is Role.ADMIN + assert issued.principal.subject == "legacy.single-key" + + +def test_unconfigured_identity_service_is_absent() -> None: + assert load_identity_service({}) is None + + +def test_session_ttl_configuration_is_validated() -> None: + with pytest.raises(ValueError, match="SESSION_TTL"): + load_identity_service( + {"P1AM_API_KEY": _OPERATOR_KEY, "P1AM_SESSION_TTL_S": "invalid"} + ) + with pytest.raises(ValueError, match="SESSION_TTL"): + load_identity_service( + {"P1AM_API_KEY": _OPERATOR_KEY, "P1AM_SESSION_TTL_S": "0"} + ) + + +def test_configured_ttl_controls_session_expiry_window() -> None: + service = load_identity_service( + {"P1AM_API_KEY": _OPERATOR_KEY, "P1AM_SESSION_TTL_S": "120"} + ) + assert service is not None + issued = service.login(_OPERATOR_KEY) + assert issued is not None + remaining = ( + issued.expires_at - issued.expires_at.now(issued.expires_at.tzinfo) + ).total_seconds() + assert 115 <= remaining <= 120 + + +def test_resolve_rejects_invalid_bearer_without_falling_back_to_key() -> None: + service = load_identity_service({"P1AM_API_KEY": _OPERATOR_KEY}) + assert service is not None + + resolved = service.resolve( + _OPERATOR_KEY, + HTTPAuthorizationCredentials(scheme="Bearer", credentials="invalid-session"), + ) + + assert resolved is None + + +def test_provider_preserves_sessions_until_identity_environment_changes() -> None: + env = {"P1AM_API_KEY": "legacy-short-key"} # pragma: allowlist secret + provider = EnvironmentIdentityProvider(lambda: env) + first = provider.get() + assert first is not None + issued = first.login("legacy-short-key") + assert issued is not None + + assert provider.get() is first + bearer = HTTPAuthorizationCredentials(scheme="Bearer", credentials=issued.token) + assert provider.get().resolve(None, bearer) == issued.principal + + env["P1AM_API_KEY"] = "replacement-short-key" # pragma: allowlist secret + replacement = provider.get() + assert replacement is not None + assert replacement is not first + assert replacement.resolve(None, bearer) is None + + +def test_legacy_keys_preserve_existing_nonempty_length_contract() -> None: + service = load_identity_service({"P1AM_API_KEY": "short-key"}) # noqa: E501 # pragma: allowlist secret + assert service is not None + assert service.login("short-key") is not None diff --git a/src/p1am_control_system/backend/tests/test_identity_main_integration.py b/src/p1am_control_system/backend/tests/test_identity_main_integration.py new file mode 100644 index 0000000000..b5c8edeee9 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_identity_main_integration.py @@ -0,0 +1,71 @@ +"""Application composition contract for the named identity surface.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +os.environ.setdefault("PLC_DRIVER", "modbus") +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from _route_inventory import methods_by_path as _methods_by_path # noqa: E402 +from audit_middleware import MutationAuditMiddleware # noqa: E402 +from fastapi import APIRouter, FastAPI # noqa: E402 +from main import _configuration_revision, app, configuration_workflow # noqa: E402 + + +def test_route_inventory_resolves_routes_behind_an_included_router() -> None: + """The inventory must see through `include_router`, not around it. + + From FastAPI 0.141 an included router appears in ``app.routes`` as a single + pathless ``_IncludedRouter`` marker that does not expose its children. An + inventory that skipped such markers returned an empty mapping, which turned + every downstream assertion into a vacuous pass. + """ + router = APIRouter() + + @router.post("/session") + def _create() -> dict[str, str]: + return {} + + probe = FastAPI() + probe.include_router(router, prefix="/api/auth") + + assert _methods_by_path(probe) == {"/api/auth/session": {"POST"}} + + +def test_main_application_mounts_identity_session_routes() -> None: + methods_by_path = _methods_by_path(app) + + assert methods_by_path, "route inventory empty; assertions below are vacuous" + + assert "POST" in methods_by_path["/api/auth/session"] + assert "DELETE" in methods_by_path["/api/auth/session"] + assert "GET" in methods_by_path["/api/auth/me"] + assert "GET" in methods_by_path["/api/audit"] + assert "GET" in methods_by_path["/api/alarm-management/active"] + assert "POST" in methods_by_path["/api/alarm-management/{tag}/shelf"] + assert "POST" in methods_by_path["/api/configurations/drafts"] + assert "POST" in methods_by_path["/api/configurations/{revision_id}/activate"] + assert "GET" in methods_by_path["/api/system/identity"] + assert "GET" in methods_by_path["/api/system/health"] + assert "POST" in methods_by_path["/api/system/backups"] + assert "POST" in methods_by_path["/api/system/restores"] + assert "GET" in methods_by_path["/api/acceptance/scenarios/representative"] + assert "POST" in methods_by_path["/api/acceptance/scenarios/run"] + + +def test_main_application_registers_automatic_mutation_audit() -> None: + assert any( + middleware.cls is MutationAuditMiddleware for middleware in app.user_middleware + ) + + +def test_audit_revision_resolves_the_identified_active_configuration( + monkeypatch, +) -> None: + active = type("ActiveRevision", (), {"activation_identity": "cfg-000042-proof"})() + monkeypatch.setattr(configuration_workflow, "active", lambda: active) + + assert _configuration_revision() == "cfg-000042-proof" diff --git a/src/p1am_control_system/backend/tests/test_identity_router.py b/src/p1am_control_system/backend/tests/test_identity_router.py new file mode 100644 index 0000000000..dea69455b9 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_identity_router.py @@ -0,0 +1,161 @@ +"""API tests for named SCADA session issuance and role enforcement.""" + +from __future__ import annotations + +import sys +from datetime import timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from fastapi import Depends, FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from identity import ( # noqa: E402 + CredentialRegistry, + Role, + SessionStore, + parse_principal_config, +) +from identity_router import ( # noqa: E402 + IdentityService, + create_identity_router, + require_role, +) + +_OPERATOR_KEY = "operator-test-secret" # pragma: allowlist secret +_ENGINEER_KEY = "engineer-test-secret" # pragma: allowlist secret + + +def _service() -> IdentityService: + records = parse_principal_config( + '[{"subject":"operator.one","display_name":"Operator One",' + '"role":"operator","api_key":"operator-test-secret"},' # noqa: E501 # pragma: allowlist secret + '{"subject":"engineer.one","display_name":"Engineer One",' + '"role":"engineer","api_key":"engineer-test-secret"}]' # noqa: E501 # pragma: allowlist secret + ) + return IdentityService( + CredentialRegistry(records), + SessionStore(ttl=timedelta(minutes=30)), + ) + + +def _client() -> TestClient: + service = _service() + app = FastAPI() + app.include_router(create_identity_router(service)) + + @app.post("/operator", dependencies=[Depends(require_role(service, Role.OPERATOR))]) + async def operator_action() -> dict[str, str]: + return {"status": "ok"} + + @app.post("/engineer", dependencies=[Depends(require_role(service, Role.ENGINEER))]) + async def engineer_action() -> dict[str, str]: + return {"status": "ok"} + + return TestClient(app) + + +def _login(client: TestClient, key: str) -> str: + response = client.post("/api/auth/session", headers={"X-API-Key": key}) + assert response.status_code == 201 + return str(response.json()["token"]) + + +def test_login_returns_named_principal_and_opaque_session() -> None: + client = _client() + + response = client.post( + "/api/auth/session", + headers={"X-API-Key": _OPERATOR_KEY}, + ) + + assert response.status_code == 201 + payload = response.json() + assert payload["principal"] == { + "subject": "operator.one", + "display_name": "Operator One", + "role": "operator", + } + assert len(payload["token"]) >= 32 + assert payload["expires_at"].endswith("Z") + + +def test_login_rejects_invalid_credential_without_echoing_it() -> None: + client = _client() + invalid = "invalid-test-secret" # pragma: allowlist secret + + response = client.post("/api/auth/session", headers={"X-API-Key": invalid}) + + assert response.status_code == 401 + assert invalid not in response.text + + +def test_me_resolves_bearer_session() -> None: + client = _client() + token = _login(client, _OPERATOR_KEY) + + response = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + assert response.json()["subject"] == "operator.one" + + +def test_logout_revokes_session() -> None: + client = _client() + token = _login(client, _OPERATOR_KEY) + headers = {"Authorization": f"Bearer {token}"} + + assert client.delete("/api/auth/session", headers=headers).status_code == 204 + assert client.get("/api/auth/me", headers=headers).status_code == 401 + + +def test_role_dependency_enforces_operator_and_engineer_boundaries() -> None: + client = _client() + operator = _login(client, _OPERATOR_KEY) + engineer = _login(client, _ENGINEER_KEY) + + assert ( + client.post( + "/operator", headers={"Authorization": f"Bearer {operator}"} + ).status_code + == 200 + ) + assert ( + client.post( + "/engineer", headers={"Authorization": f"Bearer {operator}"} + ).status_code + == 403 + ) + assert ( + client.post( + "/engineer", headers={"Authorization": f"Bearer {engineer}"} + ).status_code + == 200 + ) + + +def test_role_dependency_accepts_named_api_key_during_migration() -> None: + client = _client() + + response = client.post("/operator", headers={"X-API-Key": _OPERATOR_KEY}) + + assert response.status_code == 200 + + +def test_router_resolves_service_provider_at_request_time() -> None: + configured: IdentityService | None = None + app = FastAPI() + app.include_router(create_identity_router(lambda: configured)) + client = TestClient(app) + + assert client.post("/api/auth/session").status_code == 503 + configured = _service() + assert ( + client.post( + "/api/auth/session", headers={"X-API-Key": _OPERATOR_KEY} + ).status_code + == 201 + ) diff --git a/src/p1am_control_system/backend/tests/test_notification_policy.py b/src/p1am_control_system/backend/tests/test_notification_policy.py new file mode 100644 index 0000000000..2b5cc7db2b --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_notification_policy.py @@ -0,0 +1,116 @@ +"""F14 deterministic notification delay, suppression, and escalation contracts.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from notification_policy import AlarmNotice, NotificationPolicy, NotificationService + + +class RecordingChannel: + def __init__(self) -> None: + self.sent: list[tuple[str, str]] = [] + + def send(self, recipient: str, message: str) -> None: + self.sent.append((recipient, message)) + + +def _notice( + alarm_id: str, now: datetime, message: str = "Synthetic alarm" +) -> AlarmNotice: + return AlarmNotice( + alarm_id=alarm_id, + priority="high", + occurred_at=now, + message=message, + ) + + +def test_delay_then_escalation_and_delivery_audit_are_deterministic() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + channel = RecordingChannel() + service = NotificationService( + NotificationPolicy( + initial_delay=timedelta(minutes=1), + escalation_delay=timedelta(minutes=3), + primary_recipient="synthetic.on-call.primary", + escalation_recipient="synthetic.on-call.escalation", + ), + channel, + now=lambda: clock[0], + ) + service.raise_alarm(_notice("SYNTHETIC.ALARM.HIGH", clock[0])) + + assert service.tick() == [] + clock[0] += timedelta(minutes=1) + primary = service.tick() + clock[0] += timedelta(minutes=2) + escalated = service.tick() + + assert primary[0].recipient == "synthetic.on-call.primary" + assert escalated[0].recipient == "synthetic.on-call.escalation" + assert channel.sent == [ + ("synthetic.on-call.primary", "SYNTHETIC.ALARM.HIGH: Synthetic alarm"), + ("synthetic.on-call.escalation", "SYNTHETIC.ALARM.HIGH: Synthetic alarm"), + ] + assert [audit.outcome for audit in service.audit()] == ["delivered", "delivered"] + + +def test_suppression_acknowledgment_cancellation_and_redaction() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + channel = RecordingChannel() + service = NotificationService( + NotificationPolicy( + initial_delay=timedelta(seconds=10), + escalation_delay=timedelta(minutes=1), + primary_recipient="synthetic.primary", + escalation_recipient="synthetic.escalation", + suppressed_alarm_ids=frozenset({"SYNTHETIC.ALARM.SUPPRESSED"}), + ), + channel, + now=lambda: clock[0], + ) + service.raise_alarm(_notice("SYNTHETIC.ALARM.SUPPRESSED", clock[0])) + service.raise_alarm( + _notice( + "SYNTHETIC.ALARM.ACKED", + clock[0], + "Synthetic alarm password=do-not-expose", + ) + ) + service.acknowledge("SYNTHETIC.ALARM.ACKED", "operator.one") + clock[0] += timedelta(minutes=2) + + assert service.tick() == [] + assert channel.sent == [] + assert {audit.outcome for audit in service.audit()} == {"suppressed", "cancelled"} + assert all("do-not-expose" not in audit.message for audit in service.audit()) + + +def test_rate_limit_blocks_burst_and_records_attempt() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + channel = RecordingChannel() + service = NotificationService( + NotificationPolicy( + initial_delay=timedelta(0), + escalation_delay=timedelta(hours=1), + primary_recipient="synthetic.primary", + escalation_recipient="synthetic.escalation", + max_deliveries=1, + rate_limit_window=timedelta(minutes=5), + ), + channel, + now=lambda: clock[0], + ) + service.raise_alarm(_notice("SYNTHETIC.ALARM.ONE", clock[0])) + service.raise_alarm(_notice("SYNTHETIC.ALARM.TWO", clock[0])) + + service.tick() + + assert len(channel.sent) == 1 + assert [audit.outcome for audit in service.audit()] == ["delivered", "rate_limited"] diff --git a/src/p1am_control_system/backend/tests/test_operations_router.py b/src/p1am_control_system/backend/tests/test_operations_router.py new file mode 100644 index 0000000000..c6069a6731 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_operations_router.py @@ -0,0 +1,154 @@ +"""REST integration for investigations, asset health, and shift handover.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from asset_health import AssetHealthPolicy, AssetHealthService, AssetObservation +from fastapi import FastAPI +from fastapi.testclient import TestClient +from identity import Principal, Role +from operations_router import create_operations_router +from saved_investigation import InvestigationService, SqliteInvestigationRepository +from shift_log import ShiftLogService +from shift_log_repository import SqliteShiftLogRepository +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _client() -> TestClient: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + + def factory() -> Session: + return Session(engine) + + investigations = InvestigationService( + SqliteInvestigationRepository(factory), now=lambda: now + ) + shifts = ShiftLogService(SqliteShiftLogRepository(factory), now=lambda: now) + health = AssetHealthService(AssetHealthPolicy(), now=lambda: now) + observations = ( + AssetObservation( + observed_at=now - timedelta(minutes=10), + value=10, + reference=10, + command=True, + feedback=True, + running=True, + ), + AssetObservation( + observed_at=now, + value=10, + reference=10, + command=True, + feedback=True, + running=True, + ), + ) + app = FastAPI() + app.include_router( + create_operations_router( + investigations, + shifts, + asset_report_provider=lambda: health.assess( + "SYNTHETIC.FEED.PUMP", + observations, + calibration_due_at=now + timedelta(days=1), + ), + operator_dependency=lambda: Principal( + "operator.one", "Operator One", Role.OPERATOR + ), + ) + ) + return TestClient(app) + + +def _investigation() -> dict[str, object]: + start = datetime(2026, 8, 3, 19, 0, tzinfo=UTC) + return { + "title": "Synthetic feed review", + "query": { + "tags": ["SYNTHETIC.FEED.FLOW"], + "start": start.isoformat(), + "end": (start + timedelta(hours=1)).isoformat(), + "max_points": 1000, + }, + "tag_metadata": [ + { + "tag": "SYNTHETIC.FEED.FLOW", + "description": "Representative flow", + "unit": "%", + "source": "synthetic_driver", + } + ], + "charts": [ + { + "chart_id": "flow", + "kind": "trend", + "tags": ["SYNTHETIC.FEED.FLOW"], + } + ], + "bad_data_policy": "preserve", + "context": "Synthetic only", + } + + +def test_investigation_create_fetch_and_checksum_export() -> None: + client = _client() + + created = client.post("/api/operator/investigations", json=_investigation()) + investigation_id = created.json()["investigation_id"] + fetched = client.get(f"/api/operator/investigations/{investigation_id}") + exported = client.get(f"/api/operator/investigations/{investigation_id}/export") + + assert created.status_code == 200 + assert fetched.json() == created.json() + assert len(exported.headers["X-Artifact-SHA256"]) == 64 + assert exported.headers["X-Investigation-ID"] == investigation_id + assert exported.content.startswith(b"PK") + + +def test_asset_health_report_is_advisory_not_trip() -> None: + response = _client().get("/api/operator/assets/health/representative") + + assert response.status_code == 200 + assert response.json()["asset_id"] == "SYNTHETIC.FEED.PUMP" + assert response.json()["data_classification"] == "synthetic" + + +def test_shift_entry_signoff_and_handover_workflow() -> None: + client = _client() + created = client.post( + "/api/operator/shift-log", + json={ + "shift_id": "SYNTHETIC.SHIFT.NIGHT", + "run_id": "SYNTHETIC.RUN.0042", + "summary": "Synthetic handover entry", + "unresolved_actions": ["Review representative calibration"], + "event_references": [], + "trend_references": [], + }, + ) + entry_id = created.json()["entry_id"] + signoff = client.post(f"/api/operator/shift-log/{entry_id}/signoff") + handover = client.post( + f"/api/operator/shift-log/{entry_id}/handover", + json={"note": "Accepted by receiving synthetic shift"}, + ) + search = client.get("/api/operator/shift-log", params={"query": "handover"}) + + assert created.status_code == 200 + assert len(signoff.json()["content_sha256"]) == 64 + assert handover.json()["acknowledged_by"] == "operator.one" + assert search.json()[0]["entry_id"] == entry_id diff --git a/src/p1am_control_system/backend/tests/test_operator_router.py b/src/p1am_control_system/backend/tests/test_operator_router.py new file mode 100644 index 0000000000..e413e33d87 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_operator_router.py @@ -0,0 +1,83 @@ +"""REST contracts for the synthetic operator workspace.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from identity import Principal, Role +from operator_router import create_operator_router +from protection_management import ProtectionService, representative_protections + + +def _client(role: Role = Role.ENGINEER) -> tuple[TestClient, ProtectionService]: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + service = ProtectionService(representative_protections(), now=lambda: now) + app = FastAPI() + app.include_router( + create_operator_router( + service, + engineer_dependency=lambda: Principal("engineer", "Engineer", role), + ) + ) + return TestClient(app), service + + +def test_overview_is_explicitly_synthetic_and_multi_area() -> None: + client, _ = _client() + + response = client.get("/api/operator/overview") + + assert response.status_code == 200 + assert response.json()["data_classification"] == "synthetic" + assert len(response.json()["areas"]) == 3 + + +def test_trip_and_bypass_endpoints_return_operator_context() -> None: + client, _ = _client() + + trip = client.post( + "/api/operator/protections/SYNTHETIC.REACTOR.HIGH_PRESSURE/trips", + json={"group_id": "fat-trip-1"}, + ) + bypass = client.post( + "/api/operator/protections/SYNTHETIC.REACTOR.HIGH_PRESSURE/bypasses", + json={ + "reason": "Synthetic FAT verification", + "expires_at": ( + datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + timedelta(hours=1) + ).isoformat(), + }, + ) + snapshot = client.get("/api/operator/protections") + + assert trip.status_code == 200 + assert trip.json()["first_out"] is True + assert bypass.status_code == 200 + assert bypass.json()["banner_required"] is True + assert ( + snapshot.json()["active_bypasses"][0]["reason"] == "Synthetic FAT verification" + ) + + +def test_non_bypassable_endpoint_maps_policy_conflict() -> None: + client, _ = _client() + + response = client.post( + "/api/operator/protections/SYNTHETIC.REACTOR.INDEPENDENT_TRIP/bypasses", + json={ + "reason": "Attempted synthetic bypass", + "expires_at": ( + datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + timedelta(hours=1) + ).isoformat(), + }, + ) + + assert response.status_code == 409 + assert "non-bypassable" in response.json()["detail"] diff --git a/src/p1am_control_system/backend/tests/test_poll_once.py b/src/p1am_control_system/backend/tests/test_poll_once.py index 341b0bd30b..ccc746e819 100644 --- a/src/p1am_control_system/backend/tests/test_poll_once.py +++ b/src/p1am_control_system/backend/tests/test_poll_once.py @@ -21,6 +21,7 @@ from main import _connect_once, _poll_once # noqa: E402 from models import EventLog, RoutingConfig # noqa: E402 +from signal_quality import SignalFrameFactory # noqa: E402 class _Status: @@ -141,7 +142,11 @@ async def test_poll_once_offline_falls_back_to_simulator_and_broadcasts_payload( ws = _FakeWsManager() logged_scans: list[dict[str, float]] = [] - def fake_log_scan(_session: _FakeSession, tags: dict[str, float]) -> int: + def fake_log_scan( + _session: _FakeSession, + tags: dict[str, float], + **_: object, + ) -> int: logged_scans.append(dict(tags)) return len(tags) @@ -163,6 +168,8 @@ def fake_log_scan(_session: _FakeSession, tags: dict[str, float]) -> int: assert latest == {"TAG_0": 2.5, "TAG_1": 10.0} assert power.seen_tags == [{"TAG_0": 2.5, "TAG_1": 10.0}] assert payload["tags"][:2] == [2.5, 10.0] + assert payload["tag_samples"]["TAG_0"]["quality"] == "simulated" + assert payload["comms_health"]["quality"] == "simulated" assert ws.messages == [payload] assert logged_scans == [{"TAG_0": 2.5, "TAG_1": 10.0}] assert len(session.added) == 1 @@ -184,6 +191,15 @@ async def test_poll_once_connected_read_hiccup_holds_last_good() -> None: simulator = _FakeSimulator({"TAG_0": 0.0, "TAG_1": 0.0}) # must NOT be used power = _FakePowerSupply() ws = _FakeWsManager() + alarm_calls: list[dict[str, float]] = [] + + def process_events( + _engine: object, + tags: dict[str, float], + _active: dict[str, dict[str, Any]], + ) -> list[EventLog]: + alarm_calls.append(tags) + return [] payload = await _poll_once( plc=plc, @@ -196,13 +212,18 @@ async def test_poll_once_connected_read_hiccup_holds_last_good() -> None: active_alarm_map={}, session_factory=lambda: _session_factory(session), estop_active=False, - log_scan=lambda _s, _t: 0, + log_scan=lambda _s, _t, **_kw: 0, + process_events=process_events, ) assert simulator.read_count == 0 # simulator never consulted while connected assert latest == last_good # held, not zeroed assert payload["tags"][:2] == [56.5, 2.5] + assert payload["tag_samples"]["TAG_0"]["quality"] == "stale" + assert payload["tag_samples"]["TAG_0"]["diagnostic_reason"] == "read_timeout" + assert payload["comms_health"]["quality"] == "stale" assert power.seen_tags == [last_good] + assert alarm_calls == [] @pytest.mark.asyncio @@ -230,7 +251,11 @@ async def test_poll_once_reasserts_estop_every_connected_scan() -> None: async def test_poll_once_rolls_back_historian_and_alarm_transaction() -> None: session = _FakeSession() - def failing_log_scan(_session: _FakeSession, _tags: dict[str, float]) -> int: + def failing_log_scan( + _session: _FakeSession, + _tags: dict[str, float], + **_: object, + ) -> int: raise RuntimeError("disk unavailable") await _poll_once( @@ -252,6 +277,31 @@ def failing_log_scan(_session: _FakeSession, _tags: dict[str, float]) -> int: assert session.closed is True +@pytest.mark.asyncio +async def test_poll_frames_increment_one_shared_scan_sequence() -> None: + factory = SignalFrameFactory() + sequences: list[int] = [] + latest = {"TAG_0": 0.0} + for value in (1.0, 2.0): + payload = await _poll_once( + plc=_FakePLC(connected=True, tags={"TAG_0": value}), + backup=_FakeSimulator(None), + latest_tag_values=latest, + ws=_FakeWsManager(), + alicats=_FakeAlicats(), + power_supply=_FakePowerSupply(), + alarm_engine=_FakeAlarmEngine(), + active_alarm_map={}, + session_factory=lambda: _session_factory(_FakeSession()), + estop_active=False, + signal_frames=factory, + log_scan=lambda _s, _t, **_kw: 0, + ) + sequences.append(payload["comms_health"]["sequence"]) + + assert sequences == [1, 2] + + @pytest.mark.asyncio async def test_connect_once_syncs_routing_and_reasserts_estop( monkeypatch: pytest.MonkeyPatch, diff --git a/src/p1am_control_system/backend/tests/test_process_overview.py b/src/p1am_control_system/backend/tests/test_process_overview.py new file mode 100644 index 0000000000..89af16df90 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_process_overview.py @@ -0,0 +1,56 @@ +"""F06 contracts for a reusable, synthetic process navigation model.""" + +from __future__ import annotations + +import pytest +from process_overview import ( + AssetFaceplate, + ProcessOverview, + synthetic_process_overview, +) + + +def test_synthetic_overview_supports_progressive_multi_area_navigation() -> None: + overview = synthetic_process_overview() + + assert overview.data_classification == "synthetic" + assert overview.not_for_live_control is True + assert [area.area_id for area in overview.areas] == [ + "SYNTHETIC.FEED", + "SYNTHETIC.REACTOR", + "SYNTHETIC.SEPARATION", + ] + assert all(area.assets for area in overview.areas) + assert all( + asset.detail_route.startswith("/operator/assets/") + for area in overview.areas + for asset in area.assets + ) + assert all(asset.trend_tags for area in overview.areas for asset in area.assets) + + +def test_faceplate_exposes_consistent_operator_context() -> None: + asset = synthetic_process_overview().areas[1].assets[0] + + assert asset.quality in {"good", "uncertain", "bad", "stale", "simulated"} + assert asset.mode in {"off", "manual", "automatic", "unavailable"} + assert asset.alarm_state in {"normal", "active", "shelved", "suppressed"} + assert asset.interlock_state in {"clear", "permissive_missing", "tripped"} + assert asset.primary_value.unit + assert asset.primary_value.source_timestamp is not None + + +def test_overview_rejects_non_synthetic_identifiers() -> None: + asset = synthetic_process_overview().areas[0].assets[0] + + with pytest.raises(ValueError, match="SYNTHETIC"): + ProcessOverview( + overview_id="PLANT.CONFIDENTIAL", + title="Invalid", + areas=synthetic_process_overview().areas, + data_classification="synthetic", + not_for_live_control=True, + ) + + with pytest.raises(ValueError, match="SYNTHETIC"): + AssetFaceplate(**{**asset.model_dump(), "asset_id": "REAL.TAG"}) diff --git a/src/p1am_control_system/backend/tests/test_product_router.py b/src/p1am_control_system/backend/tests/test_product_router.py new file mode 100644 index 0000000000..4ddd14fb63 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_product_router.py @@ -0,0 +1,104 @@ +"""REST surface for reusable procedure, connector, notification, and HA contracts.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +from availability import AvailabilityPolicy, AvailabilityService +from connector_plugins import ConnectorDescriptor, ConnectorManager +from fastapi import FastAPI +from fastapi.testclient import TestClient +from identity import Principal, Role +from notification_policy import NotificationPolicy, NotificationService +from product_router import create_product_router +from synthetic_procedure import SyntheticProcedure + + +class Connector: + descriptor = ConnectorDescriptor( + connector_id="SYNTHETIC.CONNECTOR.DEMO", + version="1.0", + tags=("SYNTHETIC.DEMO.PV",), + ) + + def read(self) -> dict[str, float]: + return {"SYNTHETIC.DEMO.PV": 1.0} + + def write(self, tag: str, value: float) -> None: + raise AssertionError("no writable tags") + + def diagnostics(self) -> dict[str, object]: + return {"state": "online"} + + +class Channel: + def send(self, recipient: str, message: str) -> None: + return None + + +def _client() -> TestClient: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + procedure = SyntheticProcedure(now=lambda: now) + connectors = ConnectorManager((Connector(),)) + notifications = NotificationService( + NotificationPolicy( + initial_delay=timedelta(minutes=1), + escalation_delay=timedelta(minutes=5), + primary_recipient="synthetic.primary", + escalation_recipient="synthetic.escalation", + ), + Channel(), + now=lambda: now, + ) + availability = AvailabilityService( + AvailabilityPolicy( + recovery_time_objective=timedelta(minutes=5), + recovery_point_objective=timedelta(seconds=30), + max_clock_skew=timedelta(seconds=2), + buffer_capacity=100, + ) + ) + app = FastAPI() + app.include_router( + create_product_router( + procedure, + connectors, + notifications, + availability, + operator_dependency=lambda: Principal( + "operator.one", "Operator One", Role.OPERATOR + ), + ) + ) + return TestClient(app) + + +def test_product_status_exposes_all_reusable_contracts() -> None: + response = _client().get("/api/operator/product-status") + + assert response.status_code == 200 + payload = response.json() + assert payload["procedure_state"] == "idle" + assert payload["connectors"][0]["connector_id"] == "SYNTHETIC.CONNECTOR.DEMO" + assert payload["samples"]["SYNTHETIC.DEMO.PV"]["quality"] == "good" + assert payload["notification_policy"]["primary_recipient"] == "synthetic.primary" + assert payload["availability"]["recovery_time_objective_seconds"] == 300 + assert payload["data_classification"] == "synthetic" + + +def test_procedure_commands_are_role_gated_and_attributed() -> None: + client = _client() + + response = client.post( + "/api/operator/procedure/commands/start", + json={"reason": "Begin representative procedure"}, + ) + + assert response.status_code == 200 + assert response.json()["after"] == "starting" + assert response.json()["actor"] == "operator.one" diff --git a/src/p1am_control_system/backend/tests/test_protection_management.py b/src/p1am_control_system/backend/tests/test_protection_management.py new file mode 100644 index 0000000000..9f10c843f2 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_protection_management.py @@ -0,0 +1,130 @@ +"""F07 contracts for first-out capture and managed synthetic bypasses.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from identity import Principal, Role +from protection_management import ( + BypassRequest, + ProtectionCategory, + ProtectionDefinition, + ProtectionService, +) + +UTC = UTC + + +def principal(role: Role) -> Principal: + return Principal(subject=f"user.{role}", display_name=str(role).title(), role=role) + + +def service(now: datetime) -> ProtectionService: + return ProtectionService( + definitions=( + ProtectionDefinition( + protection_id="SYNTHETIC.REACTOR.HIGH_PRESSURE", + category=ProtectionCategory.INTERLOCK, + consequences=("SYNTHETIC.FEED stops", "SYNTHETIC.VENT opens"), + bypassable=True, + ), + ProtectionDefinition( + protection_id="SYNTHETIC.REACTOR.INDEPENDENT_TRIP", + category=ProtectionCategory.INDEPENDENT_PROTECTION, + consequences=("Synthetic heater power removed",), + bypassable=False, + ), + ), + now=lambda: now, + ) + + +def test_trip_group_preserves_first_out_and_consequences() -> None: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + protections = service(now) + + first = protections.trip("SYNTHETIC.REACTOR.HIGH_PRESSURE", group_id="trip-1") + second = protections.trip("SYNTHETIC.REACTOR.INDEPENDENT_TRIP", group_id="trip-1") + + assert first.first_out is True + assert second.first_out is False + assert first.consequences == ("SYNTHETIC.FEED stops", "SYNTHETIC.VENT opens") + assert second.category is ProtectionCategory.INDEPENDENT_PROTECTION + + +def test_managed_bypass_requires_role_reason_expiry_and_banner() -> None: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + protections = service(now) + request = BypassRequest( + protection_id="SYNTHETIC.REACTOR.HIGH_PRESSURE", + reason="Synthetic FAT verification", + expires_at=now + timedelta(hours=2), + ) + + with pytest.raises(PermissionError): + protections.request_bypass(request, principal(Role.OPERATOR)) + + bypass = protections.request_bypass(request, principal(Role.ENGINEER)) + + assert bypass.active is True + assert bypass.banner_required is True + assert bypass.actor == "user.engineer" + assert bypass.reason == "Synthetic FAT verification" + assert protections.active_bypasses() == [bypass] + + +def test_non_bypassable_policy_and_expiry_are_fail_closed() -> None: + now = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + protections = service(now) + + with pytest.raises(ValueError, match="non-bypassable"): + protections.request_bypass( + BypassRequest( + protection_id="SYNTHETIC.REACTOR.INDEPENDENT_TRIP", + reason="Not permitted", + expires_at=now + timedelta(minutes=5), + ), + principal(Role.ADMIN), + ) + + with pytest.raises(ValueError, match="future"): + protections.request_bypass( + BypassRequest( + protection_id="SYNTHETIC.REACTOR.HIGH_PRESSURE", + reason="Expired request", + expires_at=now, + ), + principal(Role.ENGINEER), + ) + + +def test_expired_bypass_is_not_reported_active() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + protections = ProtectionService( + definitions=( + ProtectionDefinition( + protection_id="SYNTHETIC.PUMP.LOW_FLOW", + category=ProtectionCategory.INTERLOCK, + consequences=("Synthetic pump stops",), + bypassable=True, + ), + ), + now=lambda: clock[0], + ) + protections.request_bypass( + BypassRequest( + protection_id="SYNTHETIC.PUMP.LOW_FLOW", + reason="Timed synthetic test", + expires_at=clock[0] + timedelta(minutes=1), + ), + principal(Role.ENGINEER), + ) + + clock[0] += timedelta(minutes=2) + assert protections.active_bypasses() == [] diff --git a/src/p1am_control_system/backend/tests/test_recovery_package.py b/src/p1am_control_system/backend/tests/test_recovery_package.py new file mode 100644 index 0000000000..9f59ccb2c7 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_recovery_package.py @@ -0,0 +1,109 @@ +"""Recovery-package contracts: verified, bounded, and de-energized.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_workflow import ( # noqa: E402 + ConfigurationState, + ConfigurationWorkflow, + InMemoryRevisionRepository, +) +from identity import Principal, Role # noqa: E402 +from models import InterlockConfig, RoutingConfig # noqa: E402 +from recovery_package import RecoveryPackageService # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _routing() -> RoutingConfig: + return RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=90, + hihi_limit=100, + ) + }, + ) + + +async def _active_workflow() -> tuple[ConfigurationWorkflow, list[RoutingConfig]]: + deployed: list[RoutingConfig] = [] + + async def deploy(config: RoutingConfig) -> None: + deployed.append(config) + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + engineer = Principal("engineer", "Engineer", Role.ENGINEER) + admin = Principal("admin", "Admin", Role.ADMIN) + revision = workflow.create_draft(_routing(), engineer, "Synthetic baseline") + workflow.validate(revision.revision_id, engineer) + workflow.submit_for_review(revision.revision_id, engineer) + workflow.approve(revision.revision_id, engineer, "Synthetic approval") + await workflow.activate(revision.revision_id, admin) + return workflow, deployed + + +@pytest.mark.asyncio +async def test_backup_round_trip_restores_only_as_a_draft() -> None: + workflow, deployed = await _active_workflow() + service = RecoveryPackageService( + workflow, + software_revision="software-test-1", + clock=lambda: datetime(2026, 8, 3, tzinfo=UTC), + ) + artifact = service.create() + + verified = service.verify(artifact.payload, artifact.sha256) + restored = service.restore_as_draft( + artifact.payload, + Principal("restore-engineer", "Restore Engineer", Role.ENGINEER), + "Synthetic restore exercise", + artifact.sha256, + ) + + assert verified.manifest.data_classification == "configuration_backup" + assert verified.manifest.not_for_live_control is True + assert verified.manifest.energized_state_included is False + assert restored.state is ConfigurationState.DRAFT + assert restored.source_revision_id is None + assert len(deployed) == 1 # restore did not invoke the deployment adapter + + +@pytest.mark.asyncio +async def test_tampered_or_wrongly_identified_package_is_rejected() -> None: + workflow, _deployed = await _active_workflow() + service = RecoveryPackageService(workflow, software_revision="software-test-1") + artifact = service.create() + tampered = artifact.payload[:-1] + bytes([artifact.payload[-1] ^ 1]) + + with pytest.raises(ValueError, match="checksum"): + service.verify(tampered, artifact.sha256) + + with pytest.raises(ValueError, match="checksum"): + service.verify(artifact.payload, "0" * 64) + + +def test_backup_requires_an_identified_active_revision() -> None: + async def deploy(_config: RoutingConfig) -> None: + return None + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + service = RecoveryPackageService(workflow, software_revision="software-test-1") + + with pytest.raises(ValueError, match="active"): + service.create() diff --git a/src/p1am_control_system/backend/tests/test_saved_investigation.py b/src/p1am_control_system/backend/tests/test_saved_investigation.py new file mode 100644 index 0000000000..690d3f4787 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_saved_investigation.py @@ -0,0 +1,143 @@ +"""F08 reproducible historian-investigation contracts.""" + +from __future__ import annotations + +import hashlib +import io +import zipfile +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from identity import Principal, Role +from saved_investigation import ( + BadDataPolicy, + ChartDefinition, + InvestigationQuery, + InvestigationService, + InvestigationSpec, + SqliteInvestigationRepository, + TagMetadata, + Transformation, +) +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _service() -> InvestigationService: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return InvestigationService(SqliteInvestigationRepository(lambda: Session(engine))) + + +def _spec() -> InvestigationSpec: + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + return InvestigationSpec( + title="Synthetic temperature excursion review", + query=InvestigationQuery( + tags=("SYNTHETIC.REACTOR.TEMPERATURE", "SYNTHETIC.REACTOR.SETPOINT"), + start=start, + end=start + timedelta(hours=1), + max_points=4000, + ), + tag_metadata=( + TagMetadata( + tag="SYNTHETIC.REACTOR.TEMPERATURE", + description="Representative reactor temperature", + unit="°C", + source="synthetic_driver", + ), + TagMetadata( + tag="SYNTHETIC.REACTOR.SETPOINT", + description="Representative target", + unit="°C", + source="synthetic_driver", + ), + ), + transformations=( + Transformation(operation="moving_average", parameters={"window": 5}), + ), + charts=( + ChartDefinition( + chart_id="temperature-context", + kind="trend", + tags=("SYNTHETIC.REACTOR.TEMPERATURE",), + ), + ), + annotations=("Synthetic trip at 20:23 UTC",), + event_ids=("SYNTHETIC.EVENT.0001",), + bad_data_policy=BadDataPolicy.PRESERVE, + context="Representative demonstration; no plant records.", + ) + + +def test_saved_investigation_round_trip_reproduces_complete_context() -> None: + service = _service() + principal = Principal("analyst", "Analyst", Role.ENGINEER) + + saved = service.save(_spec(), principal) + restored = service.get(saved.investigation_id) + + assert restored == saved + assert restored.created_by == "analyst" + assert restored.spec.query.tags == _spec().query.tags + assert restored.spec.tag_metadata == _spec().tag_metadata + assert restored.spec.transformations == _spec().transformations + assert restored.spec.charts == _spec().charts + assert restored.spec.annotations == _spec().annotations + assert restored.spec.event_ids == _spec().event_ids + assert restored.spec.bad_data_policy is BadDataPolicy.PRESERVE + assert len(restored.content_sha256) == 64 + + +def test_export_package_has_reproducible_checksums() -> None: + service = _service() + saved = service.save(_spec(), Principal("analyst", "Analyst", Role.ENGINEER)) + + artifact = service.export(saved.investigation_id) + + assert hashlib.sha256(artifact.payload).hexdigest() == artifact.sha256 + with zipfile.ZipFile(io.BytesIO(artifact.payload)) as archive: + assert set(archive.namelist()) == {"manifest.json", "investigation.json"} + investigation_bytes = archive.read("investigation.json") + assert ( + hashlib.sha256(investigation_bytes).hexdigest() + == artifact.manifest.entries["investigation.json"] + ) + assert b"Synthetic temperature excursion review" in investigation_bytes + + +def test_bad_data_cannot_be_silently_interpolated() -> None: + payload = _spec().model_dump() + payload["bad_data_policy"] = "interpolate" + + with pytest.raises(ValueError): + InvestigationSpec.model_validate(payload) + + +def test_query_rejects_non_synthetic_tags_and_inverted_time() -> None: + start = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + + with pytest.raises(ValueError, match="SYNTHETIC"): + InvestigationQuery( + tags=("REAL.PLANT.TAG",), + start=start, + end=start + timedelta(minutes=1), + max_points=100, + ) + + with pytest.raises(ValueError, match="after start"): + InvestigationQuery( + tags=("SYNTHETIC.TAG",), + start=start, + end=start, + max_points=100, + ) diff --git a/src/p1am_control_system/backend/tests/test_scenario_evidence.py b/src/p1am_control_system/backend/tests/test_scenario_evidence.py new file mode 100644 index 0000000000..2962a716f0 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_scenario_evidence.py @@ -0,0 +1,125 @@ +"""Declarative synthetic scenario and acceptance-evidence contracts.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from evidence_package import EvidencePackageService # noqa: E402 +from scenario_evidence import ( # noqa: E402 + RepresentativeScenarioAdapter, + ScenarioDefinition, + ScenarioRunner, + ScenarioStep, +) + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _scenario() -> ScenarioDefinition: + return ScenarioDefinition( + name="Synthetic transport fault and recovery", + data_classification="synthetic", + not_for_live_control=True, + steps=[ + ScenarioStep( + step_id="disconnect", + action="transport_disconnect", + target="SYNTHETIC.TRANSPORT", + parameters={}, + expected={"connected": False}, + timing_window_ms=100, + ), + ScenarioStep( + step_id="recover", + action="transport_recover", + target="SYNTHETIC.TRANSPORT", + parameters={}, + expected={"connected": True}, + timing_window_ms=100, + ), + ], + ) + + +@pytest.mark.asyncio +async def test_synthetic_fault_and_recovery_emit_self_contained_evidence() -> None: + now = datetime(2026, 8, 3, tzinfo=UTC) + adapter = RepresentativeScenarioAdapter(clock=lambda: now) + runner = ScenarioRunner( + adapter, + software_revision="software-test-1", + configuration_revision="cfg-000001-proof", + clock=lambda: now, + ) + evidence = await runner.run(_scenario()) + package = EvidencePackageService().create(_scenario(), evidence) + verified = EvidencePackageService().verify(package.payload, package.sha256) + + assert evidence.passed is True + assert [result.passed for result in evidence.results] == [True, True] + assert all(result.alarms for result in evidence.results) + assert all(result.audit_events for result in evidence.results) + assert evidence.results[0].alarms[0].alarm_id.startswith("SYNTHETIC.") + assert evidence.signoff.prepared_by is None + assert evidence.signoff.approved_by is None + assert verified.evidence.evidence_id == evidence.evidence_id + assert verified.manifest.data_classification == "synthetic" + + +def test_scenario_contract_rejects_non_synthetic_or_live_targets() -> None: + with pytest.raises(ValueError, match="SYNTHETIC"): + ScenarioStep( + step_id="bad", + action="set_value", + target="REAL.TAG", + parameters={"value": 1}, + expected={"value": 1}, + timing_window_ms=100, + ) + + with pytest.raises(ValueError): + ScenarioDefinition( + name="Bad classification", + data_classification="confidential", + not_for_live_control=True, + steps=[], + ) + + +@pytest.mark.asyncio +async def test_timing_window_failure_is_evidence_not_an_exception() -> None: + start = datetime(2026, 8, 3, tzinfo=UTC) + + class SlowAdapter: + async def execute(self, step): + from scenario_evidence import StepObservation + + return StepObservation( + step_id=step.step_id, + started_at=start, + completed_at=start + timedelta(milliseconds=200), + observed={"connected": False}, + ) + + runner = ScenarioRunner( + SlowAdapter(), + software_revision="software-test-1", + configuration_revision="cfg-000001-proof", + clock=lambda: start, + ) + evidence = await runner.run( + _scenario().model_copy(update={"steps": [_scenario().steps[0]]}) + ) + + assert evidence.passed is False + assert evidence.results[0].within_timing_window is False + assert "timing" in evidence.results[0].diagnostic.lower() diff --git a/src/p1am_control_system/backend/tests/test_scenario_router.py b/src/p1am_control_system/backend/tests/test_scenario_router.py new file mode 100644 index 0000000000..cb48744c17 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_scenario_router.py @@ -0,0 +1,52 @@ +"""REST contracts for isolated scenario execution and evidence download.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from evidence_package import EvidencePackageService # noqa: E402 +from identity import Principal, Role # noqa: E402 +from scenario_router import create_scenario_router # noqa: E402 + + +def _client() -> TestClient: + app = FastAPI() + app.include_router( + create_scenario_router( + identity_provider=lambda: ("software-test-1", "cfg-000001-proof"), + admin_dependency=lambda: Principal("admin", "Admin", Role.ADMIN), + ) + ) + return TestClient(app) + + +def test_representative_scenario_is_machine_marked_synthetic() -> None: + response = _client().get("/api/acceptance/scenarios/representative") + + assert response.status_code == 200 + assert response.json()["data_classification"] == "synthetic" + assert response.json()["not_for_live_control"] is True + assert all( + step["target"].startswith("SYNTHETIC.") for step in response.json()["steps"] + ) + + +def test_scenario_run_returns_verified_self_contained_evidence_zip() -> None: + client = _client() + scenario = client.get("/api/acceptance/scenarios/representative").json() + response = client.post("/api/acceptance/scenarios/run", json=scenario) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/zip" + assert response.headers["x-evidence-passed"] == "true" + verified = EvidencePackageService().verify( + response.content, response.headers["x-artifact-sha256"] + ) + assert verified.evidence.software_revision == "software-test-1" + assert verified.evidence.configuration_revision == "cfg-000001-proof" diff --git a/src/p1am_control_system/backend/tests/test_shift_log.py b/src/p1am_control_system/backend/tests/test_shift_log.py new file mode 100644 index 0000000000..bd3c16aab4 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_shift_log.py @@ -0,0 +1,126 @@ +"""F13 attributable shift-log and handover contracts.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from identity import Principal, Role +from shift_log import ( + EventReference, + ShiftEntryDraft, + ShiftLogService, + TrendReference, +) +from shift_log_repository import SqliteShiftLogRepository +from sqlalchemy import text +from sqlalchemy.exc import DatabaseError +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + + +def _fixture() -> tuple[ShiftLogService, object, callable]: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + + def factory() -> Session: + return Session(engine) + + service = ShiftLogService( + SqliteShiftLogRepository(factory), + now=lambda: datetime(2026, 8, 3, 20, 0, tzinfo=UTC), + ) + return service, engine, factory + + +def _draft() -> ShiftEntryDraft: + return ShiftEntryDraft( + shift_id="SYNTHETIC.SHIFT.2026-08-03-NIGHT", + run_id="SYNTHETIC.RUN.0042", + summary="Representative reactor temperature excursion reviewed.", + unresolved_actions=("Verify synthetic temperature calibration",), + event_references=( + EventReference( + event_id="SYNTHETIC.EVENT.0001", + occurred_at=datetime(2026, 8, 3, 19, 50, tzinfo=UTC), + ), + ), + trend_references=( + TrendReference( + investigation_id="SYNTHETIC.INVESTIGATION.0001", + content_sha256="a" * 64, + ), + ), + ) + + +def _principal(subject: str) -> Principal: + return Principal(subject, subject.title(), Role.OPERATOR) + + +def test_entry_is_attributable_searchable_and_exactly_linked() -> None: + service, _, _ = _fixture() + + entry = service.append(_draft(), _principal("operator.one")) + results = service.search("temperature") + + assert results == [entry] + assert entry.created_by == "operator.one" + assert entry.event_references[0].event_id == "SYNTHETIC.EVENT.0001" + assert entry.trend_references[0].content_sha256 == "a" * 64 + assert entry.unresolved_actions == ("Verify synthetic temperature calibration",) + + +def test_signoff_makes_entry_append_only_even_below_service_layer() -> None: + service, _, factory = _fixture() + entry = service.append(_draft(), _principal("operator.one")) + + signoff = service.sign_off(entry.entry_id, _principal("operator.one")) + + assert len(signoff.content_sha256) == 64 + with factory() as session: + with pytest.raises(DatabaseError, match="signed shift entries are append-only"): + session.exec( + text( + "UPDATE shiftentryrecord SET summary='tampered' WHERE entry_id=:id" + ), + params={"id": entry.entry_id}, + ) + session.commit() + + +def test_handover_acknowledgment_is_explicit_and_attributable() -> None: + service, _, _ = _fixture() + entry = service.append(_draft(), _principal("operator.one")) + service.sign_off(entry.entry_id, _principal("operator.one")) + + acknowledgment = service.acknowledge_handover( + entry.entry_id, + _principal("operator.two"), + "Unresolved calibration check accepted", + ) + + assert acknowledgment.acknowledged_by == "operator.two" + assert acknowledgment.note == "Unresolved calibration check accepted" + assert service.handover(entry.entry_id) == acknowledgment + + +def test_unsigned_entry_cannot_be_acknowledged() -> None: + service, _, _ = _fixture() + entry = service.append(_draft(), _principal("operator.one")) + + with pytest.raises(ValueError, match="signed off"): + service.acknowledge_handover( + entry.entry_id, + _principal("operator.two"), + "Premature acknowledgment", + ) diff --git a/src/p1am_control_system/backend/tests/test_signal_quality.py b/src/p1am_control_system/backend/tests/test_signal_quality.py new file mode 100644 index 0000000000..cbe3ad4c34 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_signal_quality.py @@ -0,0 +1,91 @@ +"""Canonical signal-quality contracts shared across every SCADA layer.""" + +from __future__ import annotations + +import math +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from signal_quality import ( # noqa: E402 + SignalFrameFactory, + SignalQuality, + SignalSample, +) + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 +NOW = datetime(2026, 8, 3, 20, 0, tzinfo=UTC) + + +def test_signal_sample_requires_complete_aware_provenance() -> None: + sample = SignalSample( + value=12.5, + source_timestamp=NOW - timedelta(milliseconds=5), + server_timestamp=NOW, + quality=SignalQuality.GOOD, + diagnostic_reason=None, + sequence=7, + source="synthetic.driver", + ) + + assert sample.value == 12.5 + assert sample.age_seconds(NOW + timedelta(seconds=1)) == pytest.approx(1.005) + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_signal_sample_rejects_nonfinite_values(value: float) -> None: + with pytest.raises(ValueError, match="finite"): + SignalSample( + value=value, + source_timestamp=NOW, + server_timestamp=NOW, + quality=SignalQuality.GOOD, + diagnostic_reason=None, + sequence=1, + source="synthetic.driver", + ) + + +def test_degraded_quality_requires_diagnostic_reason() -> None: + with pytest.raises(ValueError, match="diagnostic_reason"): + SignalSample( + value=1.0, + source_timestamp=NOW, + server_timestamp=NOW, + quality=SignalQuality.STALE, + diagnostic_reason=None, + sequence=1, + source="synthetic.driver", + ) + + +def test_frame_factory_sequences_good_stale_and_simulated_scans() -> None: + clock_values = iter([NOW, NOW + timedelta(seconds=1), NOW + timedelta(seconds=2)]) + factory = SignalFrameFactory(clock=lambda: next(clock_values)) + + good = factory.good({"TAG_0": 2.0}, source="synthetic.driver") + stale = factory.stale(good.values, source="synthetic.driver", reason="read_timeout") + simulated = factory.simulated({"TAG_0": 3.0}, source="synthetic.simulator") + + assert [frame.sequence for frame in (good, stale, simulated)] == [1, 2, 3] + assert good.samples["TAG_0"].quality is SignalQuality.GOOD + assert stale.samples["TAG_0"].quality is SignalQuality.STALE + assert stale.samples["TAG_0"].source_timestamp == good.server_timestamp + assert simulated.samples["TAG_0"].quality is SignalQuality.SIMULATED + assert good.alarm_eligible is True + assert stale.alarm_eligible is False + + +def test_factory_rejects_empty_or_malformed_tag_maps() -> None: + factory = SignalFrameFactory(clock=lambda: NOW) + with pytest.raises(ValueError, match="at least one"): + factory.good({}, source="synthetic.driver") + with pytest.raises(TypeError, match="dict"): + factory.good([]) # type: ignore[arg-type] diff --git a/src/p1am_control_system/backend/tests/test_synthetic_procedure.py b/src/p1am_control_system/backend/tests/test_synthetic_procedure.py new file mode 100644 index 0000000000..a89f79f541 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_synthetic_procedure.py @@ -0,0 +1,78 @@ +"""F09 deterministic simulator-only procedure contracts.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from datetime import UTC +except ImportError: # Python 3.10 — repo supports 3.10+ + UTC = timezone.utc # noqa: UP017 + +import pytest +from identity import Principal, Role +from synthetic_procedure import ProcedureCommand, ProcedureState, SyntheticProcedure + + +def _principal() -> Principal: + return Principal("operator.one", "Operator One", Role.OPERATOR) + + +def test_start_run_hold_resume_stop_cycle_is_deterministic_and_attributed() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + procedure = SyntheticProcedure(now=lambda: clock[0]) + + events = [ + procedure.dispatch(ProcedureCommand.START, _principal(), "Begin synthetic run"), + procedure.dispatch(ProcedureCommand.RUN, _principal(), "Start checks complete"), + procedure.dispatch(ProcedureCommand.HOLD, _principal(), "Synthetic hold"), + procedure.dispatch( + ProcedureCommand.RESUME, _principal(), "Resume synthetic run" + ), + procedure.dispatch(ProcedureCommand.STOP, _principal(), "Normal stop"), + procedure.dispatch( + ProcedureCommand.COMPLETE, _principal(), "Stop checks complete" + ), + ] + + assert [event.after for event in events] == [ + ProcedureState.STARTING, + ProcedureState.RUNNING, + ProcedureState.HOLDING, + ProcedureState.RUNNING, + ProcedureState.STOPPING, + ProcedureState.IDLE, + ] + assert all(event.actor == "operator.one" for event in events) + assert all(event.data_classification == "synthetic" for event in events) + assert procedure.state is ProcedureState.IDLE + + +def test_abort_and_recovery_are_bounded() -> None: + clock = [datetime(2026, 8, 3, 20, 0, tzinfo=UTC)] + procedure = SyntheticProcedure( + now=lambda: clock[0], + transition_timeout=timedelta(seconds=30), + ) + procedure.dispatch(ProcedureCommand.START, _principal(), "Begin synthetic run") + abort = procedure.dispatch(ProcedureCommand.ABORT, _principal(), "Synthetic fault") + recovery = procedure.dispatch( + ProcedureCommand.RECOVER, _principal(), "Recovery approved" + ) + + assert abort.after is ProcedureState.ABORTED + assert recovery.after is ProcedureState.RECOVERING + assert recovery.deadline == clock[0] + timedelta(seconds=30) + + clock[0] += timedelta(seconds=31) + timeout = procedure.enforce_deadline() + assert timeout is not None + assert timeout.after is ProcedureState.ABORTED + assert timeout.command is ProcedureCommand.TIMEOUT + + +def test_invalid_transition_is_fail_closed() -> None: + procedure = SyntheticProcedure(now=lambda: datetime(2026, 8, 3, tzinfo=UTC)) + + with pytest.raises(ValueError, match="not allowed"): + procedure.dispatch(ProcedureCommand.RUN, _principal(), "Invalid direct run") diff --git a/src/p1am_control_system/backend/tests/test_system_health.py b/src/p1am_control_system/backend/tests/test_system_health.py new file mode 100644 index 0000000000..0243b9b939 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_system_health.py @@ -0,0 +1,74 @@ +"""Deployment identity and health-center contracts.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy.pool import StaticPool +from sqlmodel import create_engine + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_workflow import ( # noqa: E402 + ConfigurationWorkflow, + InMemoryRevisionRepository, +) +from models import RoutingConfig # noqa: E402 +from recovery_package import RecoveryPackageService # noqa: E402 +from system_health import HealthStatus, SystemHealthService # noqa: E402 + +try: + from datetime import UTC +except ImportError: + UTC = timezone.utc # noqa: UP017 + + +def _service(plc_connected: bool = False) -> SystemHealthService: + async def deploy(_config: RoutingConfig) -> None: + return None + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + recovery = RecoveryPackageService(workflow, "software-test-1") + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + return SystemHealthService( + workflow=workflow, + recovery=recovery, + engine=engine, + software_revision="software-test-1", + plc_connected=lambda: plc_connected, + simulator_available=lambda: True, + clock_synchronized=lambda: None, + storage_free_bytes=lambda: 2_000_000_000, + service_running=lambda: True, + driver_identity=lambda: "representative.test.driver", + clock=lambda: datetime(2026, 8, 3, tzinfo=UTC), + ) + + +def test_identity_is_observable_even_before_first_activation() -> None: + identity = _service().identity() + + assert identity.software_revision == "software-test-1" + assert identity.configuration_revision == "unversioned" + assert identity.configuration_sha256 is None + + +def test_health_distinguishes_primary_transport_from_simulator_availability() -> None: + report = _service(plc_connected=False).report() + + checks = {check.name: check for check in report.checks} + assert report.overall is HealthStatus.DEGRADED + assert checks["database"].status is HealthStatus.GOOD + assert checks["primary_transport"].status is HealthStatus.DEGRADED + assert checks["simulator"].status is HealthStatus.GOOD + assert checks["configuration_identity"].status is HealthStatus.DEGRADED + assert checks["clock"].status is HealthStatus.DEGRADED + assert checks["storage"].status is HealthStatus.GOOD + assert checks["service"].status is HealthStatus.GOOD + assert checks["driver"].detail == "representative.test.driver" diff --git a/src/p1am_control_system/backend/tests/test_system_router.py b/src/p1am_control_system/backend/tests/test_system_router.py new file mode 100644 index 0000000000..01bc5a9a01 --- /dev/null +++ b/src/p1am_control_system/backend/tests/test_system_router.py @@ -0,0 +1,115 @@ +"""REST contracts for recovery packages and the system-health center.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy.pool import StaticPool +from sqlmodel import create_engine + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from configuration_workflow import ( # noqa: E402 + ConfigurationWorkflow, + InMemoryRevisionRepository, +) +from identity import Principal, Role # noqa: E402 +from models import InterlockConfig, RoutingConfig # noqa: E402 +from recovery_package import RecoveryPackageService # noqa: E402 +from system_health import SystemHealthService # noqa: E402 +from system_router import create_system_router # noqa: E402 + + +def _routing() -> RoutingConfig: + return RoutingConfig( + input_routing=["TAG_0"], + output_routing=[], + pids=[], + interlocks={ + "TAG_0": InterlockConfig( + lolo_limit=0, + low_limit=10, + high_limit=90, + hihi_limit=100, + ) + }, + ) + + +async def _client() -> TestClient: + async def deploy(_config: RoutingConfig) -> None: + return None + + workflow = ConfigurationWorkflow(InMemoryRevisionRepository(), deploy) + engineer = Principal("engineer", "Engineer", Role.ENGINEER) + admin = Principal("admin", "Admin", Role.ADMIN) + draft = workflow.create_draft(_routing(), engineer, "Synthetic baseline") + workflow.validate(draft.revision_id, engineer) + workflow.submit_for_review(draft.revision_id, engineer) + workflow.approve(draft.revision_id, engineer, "Synthetic approval") + await workflow.activate(draft.revision_id, admin) + recovery = RecoveryPackageService(workflow, "software-test-1") + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + health = SystemHealthService( + workflow, + recovery, + engine, + "software-test-1", + plc_connected=lambda: False, + simulator_available=lambda: True, + clock_synchronized=lambda: None, + storage_free_bytes=lambda: 2_000_000_000, + service_running=lambda: True, + driver_identity=lambda: "representative.test.driver", + ) + app = FastAPI() + app.include_router( + create_system_router( + recovery, + health, + engineer_dependency=lambda: engineer, + admin_dependency=lambda: admin, + ) + ) + return TestClient(app) + + +async def test_system_api_downloads_and_restores_verified_package() -> None: + client = await _client() + backup = client.post("/api/system/backups") + + assert backup.status_code == 200 + assert backup.headers["content-type"] == "application/zip" + checksum = backup.headers["x-artifact-sha256"] + restored = client.post( + "/api/system/restores", + content=backup.content, + headers={ + "Content-Type": "application/octet-stream", + "X-Artifact-SHA256": checksum, + "X-Change-Reason": "Synthetic recovery exercise", + }, + ) + + assert restored.status_code == 200 + assert restored.json()["state"] == "draft" + + +async def test_system_api_exposes_distinct_identity_and_health() -> None: + client = await _client() + + identity = client.get("/api/system/identity") + health = client.get("/api/system/health") + + assert identity.status_code == 200 + assert identity.json()["software_revision"] == "software-test-1" + assert identity.json()["configuration_revision"].startswith("cfg-") + assert health.status_code == 200 + assert health.json()["overall"] == "degraded" diff --git a/src/p1am_control_system/backend/tests/test_trends_endpoint.py b/src/p1am_control_system/backend/tests/test_trends_endpoint.py index fa8ee3a4b2..7cfbd04645 100644 --- a/src/p1am_control_system/backend/tests/test_trends_endpoint.py +++ b/src/p1am_control_system/backend/tests/test_trends_endpoint.py @@ -75,11 +75,25 @@ def test_response_schema_and_small_range(session: Session) -> None: end_time=_iso(4), db=session, ) - assert set(result) == {"timestamps", "values", "truncated"} # frontend contract + assert set(result) == { + "timestamps", + "values", + "qualities", + "diagnostic_reasons", + "source_timestamps", + "sequences", + "sources", + "truncated", + } assert result["truncated"] is False assert result["values"] == [0.0, 1.0, 2.0, 3.0, 4.0] assert len(result["timestamps"]) == 5 assert all(isinstance(t, str) for t in result["timestamps"]) # ISO strings + assert result["qualities"] == ["uncertain"] * 5 + assert result["diagnostic_reasons"] == ["legacy_unqualified"] * 5 + assert len(result["source_timestamps"]) == 5 + assert result["sequences"] == [0] * 5 + assert result["sources"] == ["legacy.adapter"] * 5 def test_numeric_tag_id_resolves_to_tag_name(session: Session) -> None: diff --git a/src/p1am_control_system/backend/timescale/001_schema.sql b/src/p1am_control_system/backend/timescale/001_schema.sql new file mode 100644 index 0000000000..482227d0cd --- /dev/null +++ b/src/p1am_control_system/backend/timescale/001_schema.sql @@ -0,0 +1,98 @@ +-- 001_schema.sql — plant historian core schema +-- +-- Requires: PostgreSQL 14+ with the timescaledb extension (2.9+ for the +-- hierarchical continuous aggregate created in 002). +-- +-- Idempotent: safe to re-run. Apply with the ordering in README.md. +-- +-- NOT applied automatically at application startup. Schema changes against a +-- production historian are an operator action, deliberately. + +CREATE EXTENSION IF NOT EXISTS timescaledb; + +-- --------------------------------------------------------------------------- +-- Asset hierarchy +-- +-- Mirrors the SQLModel definitions in backend/models.py (PlantArea -> +-- PlantUnit -> PlantEquipment -> TagDefinitionDb). Keeping the hierarchy in the +-- same database as the samples is the whole reason this is TimescaleDB rather +-- than a pure metrics store: it lets a query ask "every temperature in R-101" +-- instead of requiring the caller to already know the tag names. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS plant_area ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS plant_unit ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + area_id INTEGER NOT NULL REFERENCES plant_area (id) ON DELETE CASCADE, + UNIQUE (area_id, name) +); + +CREATE TABLE IF NOT EXISTS plant_equipment ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + unit_id INTEGER NOT NULL REFERENCES plant_unit (id) ON DELETE CASCADE, + UNIQUE (unit_id, name) +); + +-- Tag definitions. `name` is the natural key the controller knows (TAG_0..); +-- `id` is the surrogate the hypertable stores, so a sample costs 4 bytes of +-- identity rather than a repeated string. +-- +-- The shipper auto-registers unknown tags with name only. Engineering metadata +-- (description, units, equipment_id) is expected to be filled in afterwards and +-- is therefore all nullable — an unlabelled tag must never block ingest. +CREATE TABLE IF NOT EXISTS tag_definition ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + engineering_units TEXT, + tag_type TEXT, + equipment_id INTEGER REFERENCES plant_equipment (id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS ix_tag_definition_equipment + ON tag_definition (equipment_id); + +-- --------------------------------------------------------------------------- +-- Sample hypertable +-- +-- `quality` is present from the first migration even though the P1AM path only +-- ever writes "good" today. Adding a column to a compressed, multi-billion-row +-- hypertable later means decompressing it; a SMALLINT with a default costs +-- nothing now. Values follow the OPC UA convention (192 = Good, 0 = Bad, +-- 64 = Uncertain), which is what any future OPC UA or Sparkplug ingest will +-- already be speaking. +-- +-- Deliberately no surrogate primary key: a PK would add a unique index over +-- every row for no benefit, and this table is append-only. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS tag_sample ( + ts TIMESTAMPTZ NOT NULL, + tag_id INTEGER NOT NULL REFERENCES tag_definition (id), + value DOUBLE PRECISION NOT NULL, + quality SMALLINT NOT NULL DEFAULT 192 +); + +SELECT create_hypertable( + 'tag_sample', + 'ts', + chunk_time_interval => INTERVAL '1 day', + if_not_exists => TRUE +); + +-- Serves the dominant read pattern: one tag over a time range, ordered by time. +-- `ts DESC` matches "most recent N samples" and the trend queries Grafana emits. +CREATE INDEX IF NOT EXISTS ix_tag_sample_tag_ts + ON tag_sample (tag_id, ts DESC); + +COMMENT ON TABLE tag_sample IS + 'Raw process samples. Retention 90 days; see 004_retention.sql. Long-horizon ' + 'history lives in the tag_sample_1m / tag_sample_1h continuous aggregates.'; +COMMENT ON COLUMN tag_sample.quality IS + 'OPC UA style quality code. 192 = Good, 64 = Uncertain, 0 = Bad.'; diff --git a/src/p1am_control_system/backend/timescale/002_continuous_aggregates.sql b/src/p1am_control_system/backend/timescale/002_continuous_aggregates.sql new file mode 100644 index 0000000000..9e9de4d8ce --- /dev/null +++ b/src/p1am_control_system/backend/timescale/002_continuous_aggregates.sql @@ -0,0 +1,77 @@ +-- 002_continuous_aggregates.sql — downsampled rollups +-- +-- This is the migration that changes the retention story. Today the SQLite +-- historian enforces a byte cap by DELETING the oldest samples, so a long +-- enough horizon simply loses its history. Here, raw data ages out but +-- aggregates survive: 1-minute resolution for two years, 1-hour indefinitely. +-- +-- min and max are carried, not just avg. An averaged excursion is an invisible +-- excursion, and for process safety review the peak is the number that matters. +-- +-- sum and count are carried so the hourly rollup can compute a correctly +-- weighted mean. avg(avg) is only right when every bucket has the same sample +-- count, which is exactly what a lossy shipper cannot guarantee. + +-- --------------------------------------------------------------- 1 minute --- + +CREATE MATERIALIZED VIEW IF NOT EXISTS tag_sample_1m +WITH (timescaledb.continuous) AS +SELECT + time_bucket(INTERVAL '1 minute', ts) AS bucket, + tag_id, + avg(value) AS avg_value, + min(value) AS min_value, + max(value) AS max_value, + sum(value) AS sum_value, + count(*) AS sample_count +FROM tag_sample +GROUP BY bucket, tag_id +WITH NO DATA; + +SELECT add_continuous_aggregate_policy( + 'tag_sample_1m', + start_offset => INTERVAL '1 hour', + end_offset => INTERVAL '1 minute', + schedule_interval => INTERVAL '1 minute', + if_not_exists => TRUE +); + +CREATE INDEX IF NOT EXISTS ix_tag_sample_1m_tag_bucket + ON tag_sample_1m (tag_id, bucket DESC); + +-- ------------------------------------------------------------------ 1 hour --- +-- Hierarchical rollup: built from the 1-minute aggregate rather than from raw +-- samples, so the hourly refresh never rescans the raw hypertable. +-- Requires TimescaleDB 2.9+. + +CREATE MATERIALIZED VIEW IF NOT EXISTS tag_sample_1h +WITH (timescaledb.continuous) AS +SELECT + time_bucket(INTERVAL '1 hour', bucket) AS bucket, + tag_id, + sum(sum_value) / NULLIF(sum(sample_count), 0) AS avg_value, + min(min_value) AS min_value, + max(max_value) AS max_value, + sum(sum_value) AS sum_value, + sum(sample_count) AS sample_count +FROM tag_sample_1m +GROUP BY 1, 2 +WITH NO DATA; + +SELECT add_continuous_aggregate_policy( + 'tag_sample_1h', + start_offset => INTERVAL '6 hours', + end_offset => INTERVAL '1 hour', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE +); + +CREATE INDEX IF NOT EXISTS ix_tag_sample_1h_tag_bucket + ON tag_sample_1h (tag_id, bucket DESC); + +COMMENT ON MATERIALIZED VIEW tag_sample_1m IS + 'One-minute rollup. Retained 2 years. Query this, not tag_sample, for any ' + 'range beyond the raw retention window.'; +COMMENT ON MATERIALIZED VIEW tag_sample_1h IS + 'One-hour rollup, built hierarchically from tag_sample_1m. Retained ' + 'indefinitely — this is the permanent plant record.'; diff --git a/src/p1am_control_system/backend/timescale/003_compression.sql b/src/p1am_control_system/backend/timescale/003_compression.sql new file mode 100644 index 0000000000..ce674e80e5 --- /dev/null +++ b/src/p1am_control_system/backend/timescale/003_compression.sql @@ -0,0 +1,38 @@ +-- 003_compression.sql — columnar compression on aged chunks +-- +-- segmentby = tag_id is the setting that matters. It groups each tag's samples +-- into one compressed row-array, so a slowly-varying process value compresses +-- against itself rather than against an interleaved neighbour. This is what +-- delivers the 10-20x on float series; getting it wrong (or omitting it) gives +-- closer to 2-3x. +-- +-- orderby = ts DESC keeps the newest sample first inside a compressed batch, +-- which is the direction trend queries scan. +-- +-- 7 days matches the window in which data is still queried at raw resolution +-- often enough that decompression overhead would be felt. + +ALTER TABLE tag_sample SET ( + timescaledb.compress, + timescaledb.compress_segmentby = 'tag_id', + timescaledb.compress_orderby = 'ts DESC' +); + +SELECT add_compression_policy( + 'tag_sample', + INTERVAL '7 days', + if_not_exists => TRUE +); + +-- The 1-minute aggregate is itself large enough to be worth compressing at +-- longer horizons. The hourly rollup is small and is left uncompressed so the +-- permanent record stays cheap to query. +ALTER MATERIALIZED VIEW tag_sample_1m SET ( + timescaledb.compress = TRUE +); + +SELECT add_compression_policy( + 'tag_sample_1m', + INTERVAL '90 days', + if_not_exists => TRUE +); diff --git a/src/p1am_control_system/backend/timescale/004_retention.sql b/src/p1am_control_system/backend/timescale/004_retention.sql new file mode 100644 index 0000000000..f979a13675 --- /dev/null +++ b/src/p1am_control_system/backend/timescale/004_retention.sql @@ -0,0 +1,31 @@ +-- 004_retention.sql — age raw data out, keep aggregates +-- +-- ORDER MATTERS: these policies drop data. Do not apply this file until +-- 002_continuous_aggregates.sql has been applied AND has actually materialised +-- (check with the verification query at the bottom of README.md). Dropping raw +-- chunks before the aggregates have been built loses that history permanently. +-- +-- Contrast with the current SQLite behaviour, where P1AM_HISTORIAN_MAX_BYTES +-- purges oldest rows outright: here the raw resolution ages out but the record +-- survives at reduced resolution, forever. + +-- Raw samples: 90 days. Long enough for incident investigation at full +-- resolution and for a quarterly review; short enough to stay affordable. +SELECT add_retention_policy( + 'tag_sample', + INTERVAL '90 days', + if_not_exists => TRUE +); + +-- 1-minute rollup: 2 years. Covers year-over-year comparison and campaign +-- history at a resolution that still resolves process dynamics. +SELECT add_retention_policy( + 'tag_sample_1m', + INTERVAL '2 years', + if_not_exists => TRUE +); + +-- 1-hour rollup: NO retention policy, deliberately. This is the permanent +-- plant record. At 10k tags an hourly rollup is ~88M rows/year, which is small. +-- If this ever needs to be bounded, that is a conscious decision to destroy +-- plant history and should be made explicitly, not inherited from a default. diff --git a/src/p1am_control_system/backend/timescale/005_event_log.sql b/src/p1am_control_system/backend/timescale/005_event_log.sql new file mode 100644 index 0000000000..f61a27148a --- /dev/null +++ b/src/p1am_control_system/backend/timescale/005_event_log.sql @@ -0,0 +1,39 @@ +-- 005_event_log.sql — alarm and system events +-- +-- Mirrors backend/models.py::EventLog. Separate from tag_sample because events +-- are sparse, textual, and queried by type/severity rather than by tag range — +-- putting them in the sample hypertable would poison its compression. +-- +-- This table is what the ISA-18.2 / EEMUA 191 alarm-performance dashboard reads. +-- Without it, that dashboard has no source. + +CREATE TABLE IF NOT EXISTS event_log ( + id BIGSERIAL, + ts TIMESTAMPTZ NOT NULL, + event_type TEXT NOT NULL, -- ALARM | SYSTEM | ACKNOWLEDGE + description TEXT NOT NULL, + severity SMALLINT NOT NULL DEFAULT 0, -- 0 normal, 1 Hi/Lo, 2 HiHi/LoLo + tag_id INTEGER REFERENCES tag_definition (id) ON DELETE SET NULL, + PRIMARY KEY (id, ts) +); + +SELECT create_hypertable( + 'event_log', + 'ts', + chunk_time_interval => INTERVAL '7 days', + if_not_exists => TRUE +); + +CREATE INDEX IF NOT EXISTS ix_event_log_type_ts + ON event_log (event_type, ts DESC); + +CREATE INDEX IF NOT EXISTS ix_event_log_severity_ts + ON event_log (severity, ts DESC); + +-- Alarm history is a compliance artefact and is small relative to process +-- samples. No retention policy: keep it all. + +COMMENT ON TABLE event_log IS + 'Alarm/system/acknowledge events. Source for the EEMUA 191 alarm ' + 'performance dashboard. No retention policy — alarm history is retained ' + 'indefinitely as a compliance record.'; diff --git a/src/p1am_control_system/backend/timescale/006_roles.sql b/src/p1am_control_system/backend/timescale/006_roles.sql new file mode 100644 index 0000000000..b9fecc78b8 --- /dev/null +++ b/src/p1am_control_system/backend/timescale/006_roles.sql @@ -0,0 +1,61 @@ +-- 006_roles.sql — least-privilege roles +-- +-- Two distinct principals. Grafana must never hold write credentials to the +-- plant historian: a compromised or simply misconfigured dashboard should not +-- be able to alter the process record. +-- +-- Passwords are NOT set here. Set them out of band so this file stays safe to +-- commit: +-- ALTER ROLE grafana_ro WITH PASSWORD '...'; +-- ALTER ROLE historian_rw WITH PASSWORD '...'; + +-- --------------------------------------------------------------- read-only --- + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + CREATE ROLE grafana_ro LOGIN; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE CURRENT_CATALOG TO grafana_ro; +GRANT USAGE ON SCHEMA public TO grafana_ro; + +GRANT SELECT ON + tag_sample, tag_sample_1m, tag_sample_1h, + event_log, + tag_definition, plant_equipment, plant_unit, plant_area +TO grafana_ro; + +-- Cover objects created by later migrations too. +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT ON TABLES TO grafana_ro; + +-- -------------------------------------------------------------- shipper rw --- +-- The control node's shipper. Needs INSERT on samples and events, and needs to +-- register previously unseen tags. It does NOT get UPDATE or DELETE: the +-- historian is append-only from the controller's point of view, so a bug in the +-- shipper cannot rewrite history. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'historian_rw') THEN + CREATE ROLE historian_rw LOGIN; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE CURRENT_CATALOG TO historian_rw; +GRANT USAGE ON SCHEMA public TO historian_rw; + +GRANT INSERT ON tag_sample, event_log TO historian_rw; +GRANT SELECT, INSERT ON tag_definition TO historian_rw; +GRANT SELECT ON plant_area, plant_unit, plant_equipment TO historian_rw; +GRANT USAGE, SELECT ON SEQUENCE tag_definition_id_seq TO historian_rw; +GRANT USAGE, SELECT ON SEQUENCE event_log_id_seq TO historian_rw; + +-- Note: tag_definition also needs UPDATE for the shipper's ON CONFLICT DO +-- UPDATE upsert, which is used only to return an existing id on a race. Grant +-- it narrowly to the name column. +GRANT UPDATE (name) ON tag_definition TO historian_rw; diff --git a/src/p1am_control_system/backend/timescale/README.md b/src/p1am_control_system/backend/timescale/README.md new file mode 100644 index 0000000000..f4ac33d8ce --- /dev/null +++ b/src/p1am_control_system/backend/timescale/README.md @@ -0,0 +1,113 @@ +# TimescaleDB plant historian schema + +Versioned SQL for the Level 3 plant historian. See the epic (#4046) for how this +fits the overall architecture. + +**These migrations are not applied automatically.** The application never runs +DDL against the historian at startup. Schema changes on a plant historian are an +operator action. + +## Apply order + +Order matters. `004_retention.sql` deletes data and must not run before the +continuous aggregates from `002` have actually materialised. + +```bash +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 001_schema.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 002_continuous_aggregates.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 003_compression.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 005_event_log.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 006_roles.sql +# Only after verifying the aggregates below: +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 004_retention.sql +``` + +Every file is idempotent and safe to re-run. + +## Before applying 004 (retention) + +`004` starts dropping raw chunks older than 90 days. Confirm the aggregates are +populated first: + +```sql +-- Should return a recent bucket, not NULL. +SELECT max(bucket) FROM tag_sample_1m; +SELECT max(bucket) FROM tag_sample_1h; + +-- Continuous aggregate jobs should show recent successful runs. +SELECT job_id, last_run_started_at, last_successful_finish, last_run_status +FROM timescaledb_information.job_stats +WHERE hypertable_name IN ('tag_sample', 'tag_sample_1m'); +``` + +The aggregates are created `WITH NO DATA`, so on a database with existing +history you must backfill once before the policy keeps them current: + +```sql +CALL refresh_continuous_aggregate('tag_sample_1m', NULL, NULL); +CALL refresh_continuous_aggregate('tag_sample_1h', NULL, NULL); +``` + +On a large backlog this is slow and I/O heavy. Run it in a maintenance window. + +## Which table should a query read? + +| Time range | Read from | Why | +| ----------------- | --------------- | ----------------------------- | +| < 7 days | `tag_sample` | Full resolution, uncompressed | +| 7–90 days | `tag_sample` | Full resolution, compressed | +| 90 days – 2 years | `tag_sample_1m` | Raw is gone | +| > 2 years | `tag_sample_1h` | 1-minute rollup is gone | + +Dashboards must select the right source for the selected range. A panel pinned +to `tag_sample` silently returns nothing past 90 days, which reads as "the plant +was off" rather than "you are querying the wrong table". + +## Verifying compression + +```sql +SELECT + hypertable_name, + pg_size_pretty(before_compression_total_bytes) AS before, + pg_size_pretty(after_compression_total_bytes) AS after, + round( + before_compression_total_bytes::numeric + / NULLIF(after_compression_total_bytes, 0), 1 + ) AS ratio +FROM hypertable_compression_stats('tag_sample'); +``` + +Expect 10–20x on float process data. If the ratio is closer to 2–3x, check that +`compress_segmentby = 'tag_id'` actually applied — that setting is the single +biggest determinant of the outcome. + +## Rollback + +Retention and compression policies can be removed without data loss: + +```sql +SELECT remove_retention_policy('tag_sample'); +SELECT remove_retention_policy('tag_sample_1m'); +SELECT remove_compression_policy('tag_sample'); +``` + +Dropping the aggregates and hypertable **is** data loss: + +```sql +DROP MATERIALIZED VIEW IF EXISTS tag_sample_1h; +DROP MATERIALIZED VIEW IF EXISTS tag_sample_1m; +DROP TABLE IF EXISTS tag_sample; +``` + +To disable forwarding entirely without touching the historian, set +`P1AM_TIMESCALE_ENABLED=false` on the control node and restart. SQLite remains +the local source of truth throughout, so this is always a safe fallback. + +## Version requirements + +- PostgreSQL 14+ +- TimescaleDB 2.9+ (hierarchical continuous aggregates) + +Compression and continuous aggregates are Timescale License (TSL) features — +free to self-host, source-available rather than OSI-open. See the ADR in +`docs/` for the licensing discussion. diff --git a/src/p1am_control_system/backend/timescale_writer.py b/src/p1am_control_system/backend/timescale_writer.py new file mode 100644 index 0000000000..3a595744cb --- /dev/null +++ b/src/p1am_control_system/backend/timescale_writer.py @@ -0,0 +1,210 @@ +"""TimescaleDB implementation of :class:`~historian_shipper.RemoteHistorianWriter`. + +One responsibility: turn a batch of ``(timestamp, tag_name, value)`` tuples into +rows in the ``tag_sample`` hypertable as cheaply as possible. + +``psycopg`` is imported lazily inside :meth:`connect` so a bench Raspberry Pi +that has never installed a Postgres driver still boots the backend. Nothing in +this module is imported at application start unless remote forwarding is +actually enabled. + +Only ever touched from the shipper's worker thread, so no internal locking. +""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from historian_shipper import Sample + +__all__ = ["TimescaleWriter", "redact_dsn"] + +logger = logging.getLogger("dcs_backend.timescale_writer") + +# Matches the password field of a libpq URI or key/value DSN. +_DSN_PASSWORD_URI = re.compile(r"(?<=://)([^:/@]+):([^@]*)(?=@)") +_DSN_PASSWORD_KV = re.compile(r"(password\s*=\s*)(\S+)", re.IGNORECASE) + + +def redact_dsn(dsn: str) -> str: + """Return ``dsn`` with any password replaced by ``***``. + + A DSN reaches logs through startup banners, error paths, and diagnostics. + Redaction is applied at every one of those points, so it lives here rather + than at each call site. + + Args: + dsn: A libpq connection string, URI or key/value form. + + Returns: + The same string with the password obscured. + + Raises: + TypeError: If ``dsn`` is not a string. + """ + if not isinstance(dsn, str): + raise TypeError(f"dsn must be a str, got {type(dsn).__name__}") + redacted = _DSN_PASSWORD_URI.sub(r"\1:***", dsn) + return _DSN_PASSWORD_KV.sub(r"\1***", redacted) + + +class TimescaleWriter: + """Writes scan samples into a TimescaleDB hypertable. + + Tag names are resolved to the integer ``tag_definition.id`` surrogate key so + samples carry a 4-byte reference rather than a repeated string, and so the + asset hierarchy (area -> unit -> equipment -> tag) can be joined onto a + sample. Unknown tags are registered on first sight. + """ + + def __init__( + self, + dsn: str, + *, + connect_timeout_s: float = 5.0, + application_name: str = "p1am-historian-shipper", + ) -> None: + """Build a writer. No connection is opened until :meth:`connect`. + + Args: + dsn: libpq connection string for the historian database. + connect_timeout_s: Fail-fast bound on connection establishment. + application_name: Reported in ``pg_stat_activity``. + + Raises: + TypeError: If ``dsn`` is not a string or the timeout is not numeric. + ValueError: If ``dsn`` is empty or the timeout is not positive. + """ + if not isinstance(dsn, str): + raise TypeError(f"dsn must be a str, got {type(dsn).__name__}") + if not dsn.strip(): + raise ValueError("dsn must not be empty") + if not isinstance(connect_timeout_s, int | float) or isinstance( + connect_timeout_s, bool + ): + raise TypeError( + "connect_timeout_s must be numeric, " + f"got {type(connect_timeout_s).__name__}" + ) + if connect_timeout_s <= 0: + raise ValueError( + f"connect_timeout_s must be positive, got {connect_timeout_s}" + ) + + self._dsn = dsn + self._connect_timeout_s = float(connect_timeout_s) + self._application_name = application_name + self._conn: Any | None = None + self._tag_ids: dict[str, int] = {} + + @property + def safe_dsn(self) -> str: + """The DSN with its password redacted, for logging.""" + return redact_dsn(self._dsn) + + def connect(self) -> None: + """Open the connection and prime the tag-id cache. + + Raises: + RuntimeError: If ``psycopg`` is not installed. + Exception: Any driver-level connection error, for the shipper to + treat as a retryable failure. + """ + try: + import psycopg # noqa: PLC0415 - deliberate lazy import + except ImportError as exc: # pragma: no cover - environment dependent + raise RuntimeError( + "psycopg is required for TimescaleDB forwarding. " + "Install it with: pip install 'psycopg[binary]'" + ) from exc + + self.close() + logger.info("Connecting to plant historian at %s", self.safe_dsn) + self._conn = psycopg.connect( + self._dsn, + connect_timeout=int(self._connect_timeout_s), + application_name=self._application_name, + autocommit=True, + ) + self._load_tag_ids() + + def _load_tag_ids(self) -> None: + """Populate the name -> id cache from the remote tag definitions.""" + assert self._conn is not None + with self._conn.cursor() as cur: + cur.execute("SELECT name, id FROM tag_definition") + self._tag_ids = {name: tag_id for name, tag_id in cur.fetchall()} + logger.debug("Loaded %d tag definitions", len(self._tag_ids)) + + def _resolve_tag_id(self, name: str) -> int: + """Return the surrogate id for ``name``, registering it if unseen.""" + cached = self._tag_ids.get(name) + if cached is not None: + return cached + + assert self._conn is not None + # ON CONFLICT covers the race where another shipper (or a manual insert) + # registered the same tag between our cache miss and this statement. + with self._conn.cursor() as cur: + cur.execute( + """ + INSERT INTO tag_definition (name) + VALUES (%s) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id + """, + (name,), + ) + row = cur.fetchone() + if row is None: # pragma: no cover - RETURNING always yields a row here + raise RuntimeError(f"could not resolve a tag id for {name!r}") + tag_id = int(row[0]) + self._tag_ids[name] = tag_id + return tag_id + + def write_batch(self, samples: Sequence[Sample]) -> int: + """Insert a batch of samples using COPY. + + Args: + samples: Sequence of ``(timestamp, tag_name, value)``. + + Returns: + Number of rows written. + + Raises: + RuntimeError: If called before :meth:`connect`. + Exception: Any driver error, for the shipper to treat as a + retryable failure. + """ + if self._conn is None: + raise RuntimeError("write_batch called before connect") + if not samples: + return 0 + + rows = [(ts, self._resolve_tag_id(name), value) for ts, name, value in samples] + + # COPY is an order of magnitude cheaper than executemany for this shape + # and keeps the worker's round-trip count at one per batch. + with ( + self._conn.cursor() as cur, + cur.copy("COPY tag_sample (ts, tag_id, value) FROM STDIN") as copy, + ): + for row in rows: + copy.write_row(row) + return len(rows) + + def close(self) -> None: + """Close the connection. Idempotent; never raises.""" + conn = self._conn + self._conn = None + self._tag_ids = {} + if conn is None: + return + try: + conn.close() + except Exception: # noqa: BLE001 - close must not fail the caller + logger.debug("Timescale connection close failed", exc_info=True) diff --git a/src/p1am_control_system/deploy/grafana/dashboards/alarm-performance.json b/src/p1am_control_system/deploy/grafana/dashboards/alarm-performance.json new file mode 100644 index 0000000000..af0bcf43e9 --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/dashboards/alarm-performance.json @@ -0,0 +1,198 @@ +{ + "uid": "plant-alarm-performance", + "title": "Alarm Performance (EEMUA 191 / ISA-18.2)", + "tags": ["plant", "alarms", "compliance"], + "timezone": "utc", + "schemaVersion": 39, + "version": 1, + "editable": false, + "refresh": "5m", + "time": { "from": "now-7d", "to": "now" }, + "description": "Retrospective alarm-system performance against EEMUA 191 targets. Read-only: this dashboard does not acknowledge, shelve, or suppress anything, and is not a substitute for the operator alarm banner in the HMI.", + "panels": [ + { + "type": "text", + "title": "How to read this", + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 0 }, + "options": { + "mode": "markdown", + "content": "EEMUA 191 targets: **< 6 alarms/operator/hour** (long-term target ~1), **< 10 alarms per 10-minute window**, **< 1% of time in flood**, and the **top 10 alarms should account for < 5%** of total load. Exceeding these is an alarm-rationalisation finding, not a process fault. A red panel here means the alarm system needs review, not that the plant is unsafe." + } + }, + { + "type": "timeseries", + "title": "Alarms per hour (EEMUA target < 6)", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { "drawStyle": "bars", "fillOpacity": 60 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 6 }, + { "color": "red", "value": 12 } + ] + } + } + }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT time_bucket('1 hour', ts) AS time,\n count(*)::float AS \"alarms/hour\"\nFROM event_log\nWHERE event_type = 'ALARM' AND $__timeFilter(ts)\nGROUP BY 1\nORDER BY 1" + } + ] + }, + { + "type": "timeseries", + "title": "Peak alarms per 10 minutes (EEMUA target < 10)", + "description": "Windows above 10 are alarm floods. Sustained floods are the condition under which operators stop reading alarms at all.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { "drawStyle": "bars", "fillOpacity": 60 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 10 } + ] + } + } + }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT time_bucket('10 minutes', ts) AS time,\n count(*)::float AS \"alarms/10min\"\nFROM event_log\nWHERE event_type = 'ALARM' AND $__timeFilter(ts)\nGROUP BY 1\nORDER BY 1" + } + ] + }, + { + "type": "stat", + "title": "Time in alarm flood (EEMUA target < 1%)", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 12 }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 1 } + ] + } + } + }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "WITH windows AS (\n SELECT time_bucket('10 minutes', ts) AS bucket, count(*) AS n\n FROM event_log\n WHERE event_type = 'ALARM' AND $__timeFilter(ts)\n GROUP BY 1\n)\nSELECT 100.0 * count(*) FILTER (WHERE n >= 10) / NULLIF(count(*), 0)\n AS \"flood %\"\nFROM windows" + } + ] + }, + { + "type": "stat", + "title": "Average alarms/hour", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 12 }, + "fieldConfig": { + "defaults": { + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 6 }, + { "color": "red", "value": 12 } + ] + } + } + }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT count(*)::float\n / NULLIF(EXTRACT(EPOCH FROM ($__timeTo()::timestamptz - $__timeFrom()::timestamptz)) / 3600.0, 0)\n AS \"alarms/hour\"\nFROM event_log\nWHERE event_type = 'ALARM' AND $__timeFilter(ts)" + } + ] + }, + { + "type": "piechart", + "title": "Priority distribution (target ~80/15/5)", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT CASE severity\n WHEN 0 THEN 'Low (normal)'\n WHEN 1 THEN 'Medium (Hi/Lo)'\n WHEN 2 THEN 'High (HiHi/LoLo)'\n ELSE 'Unclassified'\n END AS metric,\n count(*)::float AS value\nFROM event_log\nWHERE event_type = 'ALARM' AND $__timeFilter(ts)\nGROUP BY 1\nORDER BY 2 DESC" + } + ] + }, + { + "type": "table", + "title": "Top 10 bad actors (should be < 5% of total load)", + "description": "The classic alarm-rationalisation starting point. In most unrationalised plants a handful of tags generate the majority of alarms; fixing those has more effect than anything else.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 18 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "WITH total AS (\n SELECT count(*)::float AS n\n FROM event_log\n WHERE event_type = 'ALARM' AND $__timeFilter(ts)\n)\nSELECT e.description AS \"Alarm\",\n t.name AS \"Tag\",\n count(*) AS \"Count\",\n round(100.0 * count(*) / NULLIF((SELECT n FROM total), 0), 1)\n AS \"% of load\"\nFROM event_log e\nLEFT JOIN tag_definition t ON t.id = e.tag_id\nWHERE e.event_type = 'ALARM' AND $__timeFilter(e.ts)\nGROUP BY e.description, t.name\nORDER BY count(*) DESC\nLIMIT 10" + } + ] + }, + { + "type": "table", + "title": "Chattering alarms (repeat within 60 s)", + "description": "An alarm that re-fires within a minute of clearing is almost always a deadband or filtering problem, not a real repeated excursion.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 18 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "WITH gaps AS (\n SELECT description,\n ts,\n lag(ts) OVER (PARTITION BY description ORDER BY ts) AS prev_ts\n FROM event_log\n WHERE event_type = 'ALARM' AND $__timeFilter(ts)\n)\nSELECT description AS \"Alarm\",\n count(*) AS \"Rapid repeats\"\nFROM gaps\nWHERE prev_ts IS NOT NULL\n AND ts - prev_ts < INTERVAL '60 seconds'\nGROUP BY description\nORDER BY 2 DESC\nLIMIT 10" + } + ] + }, + { + "type": "table", + "title": "Alarms with no acknowledge within 24 h (standing/stale)", + "description": "EEMUA target is ~zero standing alarms. A permanently active alarm trains operators to ignore the banner.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 27 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT a.ts AS \"Raised\",\n a.description AS \"Alarm\",\n t.name AS \"Tag\",\n a.severity AS \"Severity\"\nFROM event_log a\nLEFT JOIN tag_definition t ON t.id = a.tag_id\nWHERE a.event_type = 'ALARM'\n AND $__timeFilter(a.ts)\n AND NOT EXISTS (\n SELECT 1 FROM event_log k\n WHERE k.event_type = 'ACKNOWLEDGE'\n AND k.tag_id IS NOT DISTINCT FROM a.tag_id\n AND k.ts > a.ts\n AND k.ts < a.ts + INTERVAL '24 hours'\n )\nORDER BY a.ts DESC\nLIMIT 200" + } + ] + }, + { + "type": "table", + "title": "Alarms raised while the tag had no valid sample", + "description": "Detection control for the class of defect where a non-finite reading drives alarm state. An alarm whose tag has no finite sample within +/- 5 s of the event is suspicious and should be investigated. Empty is the expected result.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 36 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT e.ts AS \"Event\",\n e.event_type AS \"Type\",\n e.description AS \"Alarm\",\n t.name AS \"Tag\"\nFROM event_log e\nJOIN tag_definition t ON t.id = e.tag_id\nWHERE e.event_type IN ('ALARM', 'ACKNOWLEDGE')\n AND $__timeFilter(e.ts)\n AND NOT EXISTS (\n SELECT 1 FROM tag_sample s\n WHERE s.tag_id = e.tag_id\n AND s.ts BETWEEN e.ts - INTERVAL '5 seconds'\n AND e.ts + INTERVAL '5 seconds'\n )\nORDER BY e.ts DESC\nLIMIT 200" + } + ] + } + ] +} diff --git a/src/p1am_control_system/deploy/grafana/dashboards/campaign-comparison.json b/src/p1am_control_system/deploy/grafana/dashboards/campaign-comparison.json new file mode 100644 index 0000000000..0d3de7fc2c --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/dashboards/campaign-comparison.json @@ -0,0 +1,88 @@ +{ + "uid": "plant-campaign-comparison", + "title": "Campaign Comparison (golden batch)", + "tags": ["plant", "process", "campaign"], + "timezone": "utc", + "schemaVersion": 39, + "version": 1, + "editable": false, + "time": { "from": "now-7d", "to": "now" }, + "description": "Overlay the current campaign against a reference ('golden') run by shifting the reference in time. Use to answer 'is this run behaving like the good one?' — the question a live trend cannot answer.", + "templating": { + "list": [ + { + "name": "offset", + "label": "Reference offset", + "type": "custom", + "description": "How far back the reference campaign sits. The reference series is shifted forward by this amount so both runs line up on the same axis.", + "query": "1 day,7 days,14 days,30 days,90 days,180 days,365 days", + "current": { "text": "30 days", "value": "30 days" }, + "options": [], + "includeAll": false, + "multi": false + }, + { + "name": "tag", + "label": "Tag", + "type": "query", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "query": "SELECT name AS __text, id AS __value FROM tag_definition ORDER BY name", + "refresh": 1, + "includeAll": false, + "multi": true + } + ] + }, + "panels": [ + { + "type": "text", + "title": "Reading this", + "gridPos": { "h": 3, "w": 24, "x": 0, "y": 0 }, + "options": { + "mode": "markdown", + "content": "Solid = **current** window. Dashed/suffixed `(ref)` = the same window shifted back by **$offset**. Divergence between the two is the signal; absolute values are secondary.\n\nBoth series come from `tag_sample_1m`, so this works back to two years. Beyond that, switch the queries to `tag_sample_1h`." + } + }, + { + "type": "timeseries", + "title": "Current vs reference ($offset ago) — mean", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 12, "w": 24, "x": 0, "y": 3 }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1 } }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*\\(ref\\)$" }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { "fill": "dash", "dash": [10, 10] } + } + ] + } + ] + }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT time_bucket('$__interval', a.bucket) AS time,\n t.name AS metric,\n sum(a.sum_value) / NULLIF(sum(a.sample_count), 0) AS value\nFROM tag_sample_1m a\nJOIN tag_definition t ON t.id = a.tag_id\nWHERE $__timeFilter(a.bucket)\n AND a.tag_id IN ($tag)\nGROUP BY 1, 2\n\nUNION ALL\n\n-- Reference run, shifted forward so it overlays the current window.\nSELECT time_bucket('$__interval', a.bucket + INTERVAL '$offset') AS time,\n t.name || ' (ref)' AS metric,\n sum(a.sum_value) / NULLIF(sum(a.sample_count), 0) AS value\nFROM tag_sample_1m a\nJOIN tag_definition t ON t.id = a.tag_id\nWHERE a.bucket BETWEEN $__timeFrom()::timestamptz - INTERVAL '$offset'\n AND $__timeTo()::timestamptz - INTERVAL '$offset'\n AND a.tag_id IN ($tag)\nGROUP BY 1, 2\n\nORDER BY 1" + } + ] + }, + { + "type": "table", + "title": "Campaign statistics — current vs reference", + "description": "Whole-window summary. A shifted mean with an unchanged min/max usually indicates a setpoint change; a widened min/max with an unchanged mean usually indicates degraded control.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 15 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "WITH current_window AS (\n SELECT a.tag_id,\n sum(a.sum_value) / NULLIF(sum(a.sample_count), 0) AS mean,\n min(a.min_value) AS lo,\n max(a.max_value) AS hi\n FROM tag_sample_1m a\n WHERE $__timeFilter(a.bucket) AND a.tag_id IN ($tag)\n GROUP BY a.tag_id\n),\nreference AS (\n SELECT a.tag_id,\n sum(a.sum_value) / NULLIF(sum(a.sample_count), 0) AS mean,\n min(a.min_value) AS lo,\n max(a.max_value) AS hi\n FROM tag_sample_1m a\n WHERE a.bucket BETWEEN $__timeFrom()::timestamptz - INTERVAL '$offset'\n AND $__timeTo()::timestamptz - INTERVAL '$offset'\n AND a.tag_id IN ($tag)\n GROUP BY a.tag_id\n)\nSELECT t.name AS \"Tag\",\n round(c.mean::numeric, 3) AS \"Mean (now)\",\n round(r.mean::numeric, 3) AS \"Mean (ref)\",\n round((c.mean - r.mean)::numeric, 3) AS \"Delta\",\n round(c.lo::numeric, 3) AS \"Min (now)\",\n round(c.hi::numeric, 3) AS \"Max (now)\",\n round(r.lo::numeric, 3) AS \"Min (ref)\",\n round(r.hi::numeric, 3) AS \"Max (ref)\"\nFROM current_window c\nFULL OUTER JOIN reference r ON r.tag_id = c.tag_id\nJOIN tag_definition t ON t.id = COALESCE(c.tag_id, r.tag_id)\nORDER BY t.name" + } + ] + } + ] +} diff --git a/src/p1am_control_system/deploy/grafana/dashboards/historian-health.json b/src/p1am_control_system/deploy/grafana/dashboards/historian-health.json new file mode 100644 index 0000000000..2ee7f4931a --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/dashboards/historian-health.json @@ -0,0 +1,137 @@ +{ + "uid": "plant-historian-health", + "title": "Historian Health (ingest)", + "tags": ["plant", "historian", "diagnostics"], + "timezone": "utc", + "schemaVersion": 39, + "version": 1, + "editable": false, + "refresh": "1m", + "time": { "from": "now-24h", "to": "now" }, + "description": "Is the plant historian actually receiving data? Measured at the destination, so it detects shipper outages, network partitions, and a stopped control node alike. The control node's own shipper counters (queue depth, drops) are at GET /api/historian/shipper on the Pi — Grafana OSS cannot scrape that without an external plugin, so ingest is measured here instead.", + "panels": [ + { + "type": "text", + "title": "Why this dashboard exists", + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 0 }, + "options": { + "mode": "markdown", + "content": "A flat line on a process trend has two very different causes: **the value did not change**, or **no data arrived**. Those look identical on a chart and mean opposite things. Check here before drawing a conclusion from a flat or missing trend.\n\nForwarding is best-effort and at-most-once by design — the control node's local SQLite historian is the authoritative record. A gap here does **not** mean the data is lost, only that it did not reach this database." + } + }, + { + "type": "stat", + "title": "Ingest lag (time since newest sample)", + "description": "Should stay near the capture interval. Climbing steadily means forwarding has stopped.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 4 }, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 60 }, + { "color": "red", "value": 300 } + ] + } + } + }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT EXTRACT(EPOCH FROM (now() - max(ts))) AS \"lag\"\nFROM tag_sample" + } + ] + }, + { + "type": "stat", + "title": "Tags reporting (last hour)", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 4 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT count(DISTINCT tag_id) AS \"tags\"\nFROM tag_sample\nWHERE ts > now() - INTERVAL '1 hour'" + } + ] + }, + { + "type": "stat", + "title": "Samples ingested per minute", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 4 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT count(*)::float / 60.0 AS \"samples/min\"\nFROM tag_sample\nWHERE ts > now() - INTERVAL '1 hour'" + } + ] + }, + { + "type": "timeseries", + "title": "Ingest rate — gaps here are forwarding gaps", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 10 }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "bars", "fillOpacity": 70 }, + "unit": "short" + } + }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT time_bucket('$__interval', ts) AS time,\n count(*)::float AS \"samples\"\nFROM tag_sample\nWHERE $__timeFilter(ts)\nGROUP BY 1\nORDER BY 1" + } + ] + }, + { + "type": "table", + "title": "Stale tags (no sample in the last hour)", + "description": "A tag that stopped reporting while others continued is an instrument or mapping problem, not a shipper problem.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 19 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT t.name AS \"Tag\",\n max(s.ts) AS \"Last sample\",\n round(EXTRACT(EPOCH FROM (now() - max(s.ts)))) AS \"Age (s)\"\nFROM tag_definition t\nLEFT JOIN tag_sample s ON s.tag_id = t.id\nGROUP BY t.name\nHAVING max(s.ts) IS NULL OR max(s.ts) < now() - INTERVAL '1 hour'\nORDER BY 2 NULLS FIRST\nLIMIT 100" + } + ] + }, + { + "type": "table", + "title": "Storage and compression", + "description": "If the compression ratio is closer to 2-3x than 10-20x, check that compress_segmentby = 'tag_id' actually applied — that setting dominates the outcome.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 19 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT hypertable_name AS \"Table\",\n pg_size_pretty(before_compression_total_bytes) AS \"Before\",\n pg_size_pretty(after_compression_total_bytes) AS \"After\",\n round(before_compression_total_bytes::numeric\n / NULLIF(after_compression_total_bytes, 0), 1) AS \"Ratio\"\nFROM hypertable_compression_stats('tag_sample')" + } + ] + }, + { + "type": "table", + "title": "Continuous aggregate and retention jobs", + "description": "If the 1-minute aggregate stops refreshing, retention will eventually drop raw chunks that were never rolled up — permanent history loss.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT job_id AS \"Job\",\n hypertable_name AS \"Table\",\n last_run_status AS \"Status\",\n last_successful_finish AS \"Last success\",\n total_failures AS \"Failures\"\nFROM timescaledb_information.job_stats\nORDER BY last_successful_finish NULLS FIRST" + } + ] + } + ] +} diff --git a/src/p1am_control_system/deploy/grafana/dashboards/process-overview.json b/src/p1am_control_system/deploy/grafana/dashboards/process-overview.json new file mode 100644 index 0000000000..ffbf60524b --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/dashboards/process-overview.json @@ -0,0 +1,99 @@ +{ + "uid": "plant-process-overview", + "title": "Process Overview", + "tags": ["plant", "process"], + "timezone": "utc", + "schemaVersion": 39, + "version": 1, + "editable": false, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "description": "Process values by plant area/unit/equipment. NOT AN HMI: this dashboard is read-only and cannot control anything. If it disagrees with the HMI, the HMI is authoritative.", + "templating": { + "list": [ + { + "name": "resolution", + "label": "Resolution", + "type": "custom", + "description": "Raw samples are retained 90 days, 1-minute rollups 2 years, 1-hour indefinitely. Selecting a range beyond a source's retention returns nothing — which reads as 'the plant was off' rather than 'wrong table'. Pick to match your time range.", + "query": "tag_sample : Raw (< 90 days),tag_sample_1m : 1 minute (< 2 years),tag_sample_1h : 1 hour (all history)", + "current": { + "text": "1 minute (< 2 years)", + "value": "tag_sample_1m" + }, + "options": [], + "includeAll": false, + "multi": false + }, + { + "name": "area", + "label": "Area", + "type": "query", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "query": "SELECT name AS __text, id AS __value FROM plant_area ORDER BY name", + "refresh": 1, + "includeAll": true, + "multi": false + }, + { + "name": "tag", + "label": "Tags", + "type": "query", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "query": "SELECT t.name AS __text, t.id AS __value\nFROM tag_definition t\nLEFT JOIN plant_equipment eq ON eq.id = t.equipment_id\nLEFT JOIN plant_unit u ON u.id = eq.unit_id\nWHERE ('$area' = '$__all' OR u.area_id = $area::int)\nORDER BY t.name", + "refresh": 1, + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "type": "timeseries", + "title": "Process values — $resolution", + "description": "Mean per bucket. The band panel below shows min/max, which is where excursions are visible; an averaged excursion is an invisible excursion.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 11, "w": 24, "x": 0, "y": 0 }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 0 } + } + }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "-- Raw table has `ts`/`value`; the rollups have `bucket`/`avg_value`.\n-- The UNION keeps one panel working across all three sources.\nSELECT time_bucket('$__interval', s.ts) AS time,\n t.name AS metric,\n avg(s.value) AS value\nFROM tag_sample s\nJOIN tag_definition t ON t.id = s.tag_id\nWHERE '$resolution' = 'tag_sample'\n AND $__timeFilter(s.ts)\n AND ('$tag' = '$__all' OR s.tag_id IN ($tag))\nGROUP BY 1, 2\n\nUNION ALL\n\nSELECT time_bucket('$__interval', a.bucket) AS time,\n t.name AS metric,\n sum(a.sum_value) / NULLIF(sum(a.sample_count), 0) AS value\nFROM tag_sample_1m a\nJOIN tag_definition t ON t.id = a.tag_id\nWHERE '$resolution' = 'tag_sample_1m'\n AND $__timeFilter(a.bucket)\n AND ('$tag' = '$__all' OR a.tag_id IN ($tag))\nGROUP BY 1, 2\n\nUNION ALL\n\nSELECT time_bucket('$__interval', h.bucket) AS time,\n t.name AS metric,\n sum(h.sum_value) / NULLIF(sum(h.sample_count), 0) AS value\nFROM tag_sample_1h h\nJOIN tag_definition t ON t.id = h.tag_id\nWHERE '$resolution' = 'tag_sample_1h'\n AND $__timeFilter(h.bucket)\n AND ('$tag' = '$__all' OR h.tag_id IN ($tag))\nGROUP BY 1, 2\n\nORDER BY 1" + } + ] + }, + { + "type": "timeseries", + "title": "Excursion envelope (min / max per bucket)", + "description": "Only available from the rollups, which is the reason they carry min and max rather than just a mean.", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 11 }, + "targets": [ + { + "format": "time_series", + "rawQuery": true, + "rawSql": "SELECT time_bucket('$__interval', a.bucket) AS time,\n t.name || ' min' AS metric,\n min(a.min_value) AS value\nFROM tag_sample_1m a\nJOIN tag_definition t ON t.id = a.tag_id\nWHERE $__timeFilter(a.bucket)\n AND ('$tag' = '$__all' OR a.tag_id IN ($tag))\nGROUP BY 1, 2\n\nUNION ALL\n\nSELECT time_bucket('$__interval', a.bucket) AS time,\n t.name || ' max' AS metric,\n max(a.max_value) AS value\nFROM tag_sample_1m a\nJOIN tag_definition t ON t.id = a.tag_id\nWHERE $__timeFilter(a.bucket)\n AND ('$tag' = '$__all' OR a.tag_id IN ($tag))\nGROUP BY 1, 2\n\nORDER BY 1" + } + ] + }, + { + "type": "table", + "title": "Current values and asset context", + "datasource": { "type": "postgres", "uid": "plant-historian" }, + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 21 }, + "targets": [ + { + "format": "table", + "rawQuery": true, + "rawSql": "SELECT DISTINCT ON (t.id)\n t.name AS \"Tag\",\n t.description AS \"Description\",\n s.value AS \"Value\",\n t.engineering_units AS \"Units\",\n s.ts AS \"Sampled\",\n ar.name AS \"Area\",\n u.name AS \"Unit\",\n eq.name AS \"Equipment\"\nFROM tag_definition t\nLEFT JOIN plant_equipment eq ON eq.id = t.equipment_id\nLEFT JOIN plant_unit u ON u.id = eq.unit_id\nLEFT JOIN plant_area ar ON ar.id = u.area_id\nLEFT JOIN tag_sample s ON s.tag_id = t.id AND $__timeFilter(s.ts)\nWHERE ('$tag' = '$__all' OR t.id IN ($tag))\nORDER BY t.id, s.ts DESC" + } + ] + } + ] +} diff --git a/src/p1am_control_system/deploy/grafana/provisioning/dashboards/dashboards.yaml b/src/p1am_control_system/deploy/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000000..9d48e0faae --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,22 @@ +# File-based dashboard provisioning. +# +# Dashboards are committed JSON, not rows in Grafana's own database. A plant +# record has to be reviewable and reproducible; a dashboard someone edited in +# the UI six months ago is neither. +# +# allowUiUpdates is false, so the UI is a viewer. To change a dashboard: edit +# the JSON, open a PR, redeploy. + +apiVersion: 1 + +providers: + - name: plant-dashboards + orgId: 1 + folder: Plant + type: file + disableDeletion: true + allowUiUpdates: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/src/p1am_control_system/deploy/grafana/provisioning/datasources/timescale.yaml b/src/p1am_control_system/deploy/grafana/provisioning/datasources/timescale.yaml new file mode 100644 index 0000000000..d0c55203b9 --- /dev/null +++ b/src/p1am_control_system/deploy/grafana/provisioning/datasources/timescale.yaml @@ -0,0 +1,34 @@ +# Grafana datasource for the plant historian. +# +# The credentials here are the READ-ONLY role from +# backend/timescale/006_roles.sql. Grafana must never hold write credentials to +# the process record — a misconfigured or compromised dashboard should not be +# able to alter plant history. +# +# No secrets are committed: values come from the environment (see +# deploy/historian/.env.example). + +apiVersion: 1 + +datasources: + - name: PlantHistorian + uid: plant-historian + type: postgres + access: proxy + url: timescaledb:5432 + user: ${GRAFANA_RO_USER} + database: ${HISTORIAN_DB} + secureJsonData: + password: ${GRAFANA_RO_PASSWORD} + jsonData: + sslmode: disable # container-to-container on a private network + postgresVersion: 1600 + # Tells Grafana it may use time_bucket() and other Timescale functions. + timescaledb: true + maxOpenConns: 10 + maxIdleConns: 5 + connMaxLifetime: 14400 + # Editable false: the datasource definition lives in git. Changing it + # through the UI would silently diverge from the reviewed configuration. + editable: false + isDefault: true diff --git a/src/p1am_control_system/deploy/historian/.env.example b/src/p1am_control_system/deploy/historian/.env.example new file mode 100644 index 0000000000..2caa0db66b --- /dev/null +++ b/src/p1am_control_system/deploy/historian/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and fill in. .env must never be committed. +# +# Generate passwords with something like: +# python3 -c "import secrets; print(secrets.token_urlsafe(32))" + +# --- TimescaleDB ----------------------------------------------------------- +HISTORIAN_DB=plant_history +HISTORIAN_SUPERUSER=historian_admin +HISTORIAN_SUPERUSER_PASSWORD=CHANGE_ME + +# --- Grafana --------------------------------------------------------------- +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=CHANGE_ME + +# Read-only role created by backend/timescale/006_roles.sql. Grafana never gets +# write credentials to the plant historian. +GRAFANA_RO_USER=grafana_ro +GRAFANA_RO_PASSWORD=CHANGE_ME + +# Interface Grafana binds to. Default is loopback only; set to 0.0.0.0 to expose +# on the host, and put it behind a reverse proxy with TLS if you do. +GRAFANA_BIND=127.0.0.1 diff --git a/src/p1am_control_system/deploy/historian/README.md b/src/p1am_control_system/deploy/historian/README.md new file mode 100644 index 0000000000..0f8f2bcdc2 --- /dev/null +++ b/src/p1am_control_system/deploy/historian/README.md @@ -0,0 +1,240 @@ +# Plant historian runbook + +TimescaleDB + Grafana as a Level 3 information layer above the P1AM control +system. See the epic (#4046) for the architecture and +[ADR-007](../../../../docs/adr/ADR-007-plant-historian-timescaledb.md) for why +this stack was chosen and what would cause us to revisit it. + +## What this is and is not + +- **Is:** a long-horizon process record, plant analytics, and alarm-performance + reporting. +- **Is not:** an HMI, a control system, or an operator alarm surface. Grafana is + read-only and holds read-only database credentials. If Grafana and the HMI + disagree, **the HMI is authoritative**. + +The control node is unaffected by anything here. Its local SQLite historian +remains the source of truth, and forwarding is best-effort — see +"Delivery guarantees" below. + +## Topology + +``` +[Control Pi] [Historian host] + P1AM firmware (interlocks, PID) + FastAPI backend @ 10 Hz ──ship──▶ TimescaleDB :5432 + React HMI Grafana :3000 + SQLite (local source of truth) +``` + +Data flows **one way**. Nothing on the historian host initiates a connection +back to the control network. That is the point of the layering: a compromised +Grafana must not be a path to the PLC. Enforce it at the firewall, not by +convention. + +Run these on **separate hosts**. TimescaleDB and Grafana on the control Pi will +steal CPU from the 10 Hz scan loop and cause overruns. + +## First-time setup + +### 1. Historian host + +```bash +cd src/p1am_control_system/deploy/historian +cp .env.example .env +# Edit .env — every CHANGE_ME must be replaced. +docker compose up -d +``` + +### 2. Apply the schema + +Migrations are **not** auto-applied. See +[`../../backend/timescale/README.md`](../../backend/timescale/README.md) for the +apply order and the mandatory check before enabling retention. + +```bash +cd ../../backend/timescale +# PGPASSWORD rather than a password in the DSN: anything on a command line is +# visible in `ps` and lands in shell history. +read -rs -p "historian_admin password: " PGPASSWORD && export PGPASSWORD +export HISTORIAN_DSN="postgresql://historian_admin@localhost:5432/plant_history" +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 001_schema.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 002_continuous_aggregates.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 003_compression.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 005_event_log.sql +psql "$HISTORIAN_DSN" -v ON_ERROR_STOP=1 -f 006_roles.sql +``` + +Then set the role passwords to match `.env`: + +```sql +ALTER ROLE grafana_ro WITH PASSWORD '...'; -- GRAFANA_RO_PASSWORD +ALTER ROLE historian_rw WITH PASSWORD '...'; -- used by the Pi +``` + +Apply `004_retention.sql` only after confirming the aggregates are populating. +It drops raw chunks; running it early loses history permanently. + +### 3. Enable forwarding on the control Pi + +```bash +export P1AM_TIMESCALE_ENABLED=true +export P1AM_TIMESCALE_DSN="postgresql://historian_rw:PASSWORD@historian-host:5432/plant_history" # pragma: allowlist secret +``` + +This one does carry the password inline — it is the single configuration value +the backend reads. Put it in the systemd unit's `EnvironmentFile=` with mode +`0600` and owned by the service user, not in a shell profile. The backend never +logs it in full (see `timescale_writer.redact_dsn`), so it should not appear in +a log bundle; do not paste it into an issue either. + +Restart the backend. Startup fails loudly if `ENABLED=true` and the DSN is +empty — a historian everyone believes is recording but is not is worse than one +that is openly off. + +Optional tuning (defaults shown): + +| Variable | Default | Purpose | +| ---------------------------------- | -------- | -------------------------------------------- | +| `P1AM_TIMESCALE_QUEUE_MAX` | `100000` | Bounded forward queue; overflow drops oldest | +| `P1AM_TIMESCALE_BATCH_SIZE` | `1000` | Samples per round-trip | +| `P1AM_TIMESCALE_FLUSH_INTERVAL_S` | `1.0` | Max partial-batch latency | +| `P1AM_TIMESCALE_CONNECT_TIMEOUT_S` | `5.0` | Fail-fast connect bound | +| `P1AM_TIMESCALE_SHUTDOWN_FLUSH_S` | `5.0` | Bound on shutdown flush | + +### 4. Verify end to end + +```bash +# On the Pi — should show connected=true and a climbing shipped_total. +curl -s localhost:8000/api/historian/shipper | python3 -m json.tool + +# On the historian host — should return a recent timestamp. +psql "$HISTORIAN_DSN" -c "SELECT max(ts), count(*) FROM tag_sample;" +``` + +Then open Grafana at `http://historian-host:3000`, folder **Plant**. The +_Historian Health (ingest)_ dashboard should show lag near your capture +interval. + +## Delivery guarantees + +**At-most-once, deliberately.** The forward queue is in memory; a backend +restart discards whatever had not shipped. SQLite on the Pi holds the +authoritative copy, so a restart loses _forwarding_, never _data_. There is no +automatic backfill from SQLite — do not build anything that assumes +exactly-once. + +Under sustained backpressure the queue drops the **oldest** samples. For process +history the newest data is the operationally useful data, and an unbounded queue +on a Pi is an out-of-memory crash of the control node, which is far worse than a +gap in a trend. + +## Troubleshooting + +### Shipper will not connect + +```bash +curl -s localhost:8000/api/historian/shipper | python3 -m json.tool +``` + +`connected: false` with a rising `consecutive_failures` and a `last_error`: + +- `ConnectionRefusedError` — historian container down, or 5432 bound to + loopback only on the historian host while the Pi is remote. The compose file + binds `127.0.0.1:5432` by default; expose it on a private interface or VPN, + never on the plant network. +- `password authentication failed` — role password not set, or `.env` and the + `ALTER ROLE` diverged. +- `RuntimeError: psycopg is required` — driver not installed on the Pi: + `pip install 'psycopg[binary]'`. +- `relation "tag_definition" does not exist` — migrations not applied. + +### Queue filling / drops climbing + +`queue_depth` near `queue_max` with `dropped_total` rising means the shipper +cannot keep up or is disconnected. + +1. Check `connected`. A disconnected shipper fills the queue by definition. +2. If connected, the remote is too slow: raise `P1AM_TIMESCALE_BATCH_SIZE`, or + check historian-host disk I/O. +3. Raising `queue_max` buys time during an outage; it does not fix a sustained + rate mismatch, and it costs Pi memory. + +### Lag climbing while the process runs + +Data is not reaching the historian. Trends will have holes. Confirm on the +_Historian Health_ dashboard before interpreting any flat line as a real +measurement. + +### Compression not running / poor ratio + +```sql +SELECT * FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression'; +SELECT * FROM hypertable_compression_stats('tag_sample'); +``` + +A ratio near 2-3x instead of 10-20x almost always means +`compress_segmentby = 'tag_id'` did not apply. + +### Continuous aggregate not refreshing + +```sql +SELECT job_id, last_run_status, last_successful_finish, total_failures +FROM timescaledb_information.job_stats; +``` + +**This is the dangerous one.** If the 1-minute aggregate stops refreshing while +the retention policy keeps dropping raw chunks, history is destroyed rather than +downsampled. If aggregates are failing, remove the retention policy until it is +fixed: + +```sql +SELECT remove_retention_policy('tag_sample'); +``` + +## Backup + +The Grafana volume holds only users and preferences — dashboards live in git. +The historian volume is the plant record. + +```bash +# Logical backup (portable, slower) +docker exec plant_historian_db pg_dump -U historian_admin -Fc plant_history \ + > plant_history_$(date +%Y%m%d).dump + +# Restore +docker exec -i plant_historian_db pg_restore -U historian_admin \ + -d plant_history --clean --if-exists < plant_history_YYYYMMDD.dump +``` + +Test a restore before you need one. An untested backup is a hypothesis. + +## Rollback + +To stop forwarding without touching the historian — one variable and a restart: + +```bash +export P1AM_TIMESCALE_ENABLED=false +``` + +The backend returns to SQLite-only. No code change, no migration, no data loss: +the local historian has been recording the whole time. + +To remove the policies without losing data, see the rollback section of +[`../../backend/timescale/README.md`](../../backend/timescale/README.md). + +## Security notes + +- Grafana holds **read-only** credentials (`grafana_ro`). The shipper role + (`historian_rw`) has INSERT but no UPDATE or DELETE on samples, so a shipper + bug cannot rewrite history. +- **Grafana OSS has no per-dashboard RBAC** — that is an Enterprise feature. + Only org- and folder-level roles exist. Anyone who can log into Grafana can + see every dashboard in their org. Do not rely on Grafana for access + segregation between operating areas. +- Change the default admin password on first login. +- Anonymous access is disabled in the compose file. Keep it that way. +- Put Grafana behind a reverse proxy with TLS before exposing it beyond + loopback. +- The DSN carries a password and is redacted wherever the backend logs it. Do + not paste an unredacted DSN into an issue or a log bundle. diff --git a/src/p1am_control_system/deploy/historian/docker-compose.yml b/src/p1am_control_system/deploy/historian/docker-compose.yml new file mode 100644 index 0000000000..3e743c0e9f --- /dev/null +++ b/src/p1am_control_system/deploy/historian/docker-compose.yml @@ -0,0 +1,100 @@ +# Plant historian stack — TimescaleDB + Grafana. +# +# THIS RUNS ON A SEPARATE HOST FROM THE CONTROL PI. +# +# Do not merge these services into src/p1am_control_system/docker-compose.yml. +# TimescaleDB and Grafana are both memory- and CPU-hungry; co-locating them with +# a 10 Hz control loop on a Raspberry Pi 5 causes scan overruns. The control node +# runs the backend, the HMI, and its local SQLite historian, and nothing else. +# +# Data flows one way: Pi -> historian. Nothing here initiates a connection back +# to the control network. That is the point of the Purdue layering — a +# compromised Grafana must not become a path to the PLC. +# +# Usage: +# cp .env.example .env # then edit; .env is gitignored +# docker compose up -d +# +# Image tags are pinned. Floating tags on a plant historian mean an unplanned +# major-version upgrade during an unrelated restart. + +services: + timescaledb: + image: timescale/timescaledb:2.17.2-pg16 + container_name: plant_historian_db + restart: unless-stopped + environment: + POSTGRES_DB: ${HISTORIAN_DB:-plant_history} + POSTGRES_USER: ${HISTORIAN_SUPERUSER:?set HISTORIAN_SUPERUSER in .env} + POSTGRES_PASSWORD: ${HISTORIAN_SUPERUSER_PASSWORD:?set HISTORIAN_SUPERUSER_PASSWORD in .env} + # Reasonable starting point for a dedicated 8-16 GB historian host. + # Tune against real ingest before treating these as final. + TIMESCALEDB_TELEMETRY: "off" + command: + - postgres + - -c + - shared_buffers=2GB + - -c + - effective_cache_size=6GB + - -c + - maintenance_work_mem=512MB + - -c + - max_wal_size=4GB + - -c + - checkpoint_completion_target=0.9 + ports: + # Bound to loopback by default. The shipper reaches this over the host's + # private interface or a VPN — never expose 5432 to the plant network. + - "127.0.0.1:5432:5432" + volumes: + - historian_data:/var/lib/postgresql/data + # Migrations are mounted read-only for convenience. They are NOT + # auto-applied: docker-entrypoint-initdb.d is deliberately not used, so a + # container restart can never re-run DDL against a populated database. + - ../../backend/timescale:/migrations:ro + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${HISTORIAN_SUPERUSER} -d ${HISTORIAN_DB:-plant_history}", + ] + interval: 10s + timeout: 5s + retries: 5 + + grafana: + image: grafana/grafana-oss:11.4.0 + container_name: plant_historian_grafana + restart: unless-stopped + depends_on: + timescaledb: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?set GRAFANA_ADMIN_USER in .env} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env} + # Anonymous access off. A plant historian is not a public dashboard. + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_USERS_ALLOW_SIGN_UP: "false" + # Grafana phones home by default; a plant network should not. + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + # Consumed by the provisioned datasource. Read-only role — see + # backend/timescale/006_roles.sql. + HISTORIAN_DB: ${HISTORIAN_DB:-plant_history} + GRAFANA_RO_USER: ${GRAFANA_RO_USER:-grafana_ro} + GRAFANA_RO_PASSWORD: ${GRAFANA_RO_PASSWORD:?set GRAFANA_RO_PASSWORD in .env} + ports: + - "${GRAFANA_BIND:-127.0.0.1}:3000:3000" + volumes: + - grafana_data:/var/lib/grafana + # Provisioning is mounted read-only: dashboards live in git, not in + # Grafana's own database. A dashboard edited through the UI is + # unreviewable and unreproducible, which a plant record cannot be. + - ../grafana/provisioning:/etc/grafana/provisioning:ro + - ../grafana/dashboards:/var/lib/grafana/dashboards:ro + +volumes: + historian_data: + driver: local + grafana_data: + driver: local diff --git a/src/p1am_control_system/frontend/src/App.tsx b/src/p1am_control_system/frontend/src/App.tsx index 836e3824c3..1db0c5a357 100644 --- a/src/p1am_control_system/frontend/src/App.tsx +++ b/src/p1am_control_system/frontend/src/App.tsx @@ -24,6 +24,10 @@ import { NotificationBanner } from "./components/NotificationBanner"; import { TabBar } from "./components/TabBar"; import { HelpModal } from "./components/HelpModal"; import { CsvExporter } from "./components/CsvExporter"; +import { CommsQualityBadge } from "./components/CommsQualityBadge"; +import { ProfessionalAlarmPanel } from "./components/ProfessionalAlarmPanel"; +import { ConfigurationWorkflowPanel } from "./components/ConfigurationWorkflowPanel"; +import { SystemHealthPanel } from "./components/SystemHealthPanel"; import { useTelemetryStream } from "./hooks/useTelemetryStream"; import { TABS, @@ -84,6 +88,11 @@ const PlantHierarchy = lazy(() => default: m.PlantHierarchy, })), ); +const OperatorWorkspace = lazy(() => + import("./components/OperatorWorkspace").then((m) => ({ + default: m.OperatorWorkspace, + })), +); // Re-export domain types for back-compat with existing importers (AlarmsHeader, // EventLogView, InterlocksPanel, RoutingMatrix, ControlDashboard). @@ -142,6 +151,7 @@ export const App: React.FC = () => { eStopActive, powerSupplyStatus, temperatureStatus, + commsHealth, isConnected, setAlicats, setActiveAlarms, @@ -350,7 +360,8 @@ export const App: React.FC = () => { } }; - // Deploy configuration & write to NVRAM + // Create a protected draft. Validation, review, approval, and activation are + // intentionally separate operator actions in the workflow panel. const handleDeploy = async () => { setDeploying(true); try { @@ -374,9 +385,12 @@ export const App: React.FC = () => { })(), }; - await api.deployRouting(payload); + const revision = await api.createConfigurationDraft( + payload, + "HMI protected configuration draft", + ); triggerNotification( - "Configuration deployed & written to NVRAM successfully.", + `Draft ${revision.revision_id} created; review it in the protected workflow.`, "success", ); } catch (err) { @@ -678,22 +692,10 @@ export const App: React.FC = () => {
-
- - - {isConnected ? "CONNECTED" : "OFFLINE"} - -
+
} > + {activeTab === "operator" && visibleTabs.operator && ( + + )} + {activeTab === "powerSupply" && visibleTabs.powerSupply && ( { deploying={deploying} /> + +
+ +
)} {activeTab === "events" && visibleTabs.events && ( -
- +
+
+ +
+
+ +
+
+ +
)} @@ -1587,7 +1605,7 @@ export const App: React.FC = () => { className="btn btn-primary" style={{ width: "100%", padding: "0.5rem", fontSize: "0.85rem", marginTop: "0.5rem" }} > - {deploying ? "Deploying Configuration..." : "Commit PID Tuning"} + {deploying ? "Creating Draft..." : "Create Protected PID Draft"}
)} @@ -1619,7 +1637,7 @@ export const App: React.FC = () => { className="btn btn-primary" style={{ width: "100%", padding: "0.5rem", fontSize: "0.85rem", marginTop: "0.5rem" }} > - {deploying ? "Deploying Configuration..." : "Commit Matrix Mapping"} + {deploying ? "Creating Draft..." : "Create Protected Matrix Draft"} )} diff --git a/src/p1am_control_system/frontend/src/api/client.ts b/src/p1am_control_system/frontend/src/api/client.ts index 40e86d7c68..45945c9e06 100644 --- a/src/p1am_control_system/frontend/src/api/client.ts +++ b/src/p1am_control_system/frontend/src/api/client.ts @@ -49,6 +49,37 @@ function joinPath(path: string): string { return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`; } +/** Execute one checked request while leaving successful response decoding to callers. */ +export async function apiResponse( + path: string, + init: RequestInit = {}, +): Promise { + let res: Response; + try { + res = await fetch(joinPath(path), init); + } catch (cause) { + throw new ApiError( + `Network error calling ${path}`, + 0, + cause instanceof Error ? cause.message : cause, + ); + } + if (!res.ok) { + let detail: unknown; + try { + detail = await res.json(); + } catch { + detail = undefined; + } + const message = + detail && typeof detail === "object" && "detail" in detail + ? String((detail as { detail: unknown }).detail) + : `Request to ${path} failed with status ${res.status}`; + throw new ApiError(message, res.status, detail); + } + return res; +} + /** * Perform a JSON request against the backend. * @@ -75,30 +106,7 @@ export async function apiFetch( } init.headers = finalHeaders; - let res: Response; - try { - res = await fetch(joinPath(path), init); - } catch (cause) { - throw new ApiError( - `Network error calling ${path}`, - 0, - cause instanceof Error ? cause.message : cause, - ); - } - - if (!res.ok) { - let detail: unknown; - try { - detail = await res.json(); - } catch { - detail = undefined; - } - const message = - detail && typeof detail === "object" && "detail" in detail - ? String((detail as { detail: unknown }).detail) - : `Request to ${path} failed with status ${res.status}`; - throw new ApiError(message, res.status, detail); - } + const res = await apiResponse(path, init); // No-content responses (e.g. 204) resolve to undefined. if (res.status === 204) { diff --git a/src/p1am_control_system/frontend/src/api/endpoints.ts b/src/p1am_control_system/frontend/src/api/endpoints.ts index 25d1e61402..30f506fe9b 100644 --- a/src/p1am_control_system/frontend/src/api/endpoints.ts +++ b/src/p1am_control_system/frontend/src/api/endpoints.ts @@ -1,4 +1,4 @@ -import { apiFetch } from "./client"; +import { apiFetch, apiResponse } from "./client"; import { ladderExplorerSchema, alicatListSchema, @@ -11,6 +11,19 @@ import { captureClearResultSchema, captureConfigSchema, performanceConfigSchema, + professionalAlarmSchema, + professionalAlarmsSchema, + configurationDiffSchema, + configurationRevisionSchema, + configurationRevisionsSchema, + systemHealthSchema, + processOverviewSchema, + protectionSnapshotSchema, + assetHealthReportSchema, + shiftEntriesSchema, + productStatusSchema, + advisoryResultSchema, + advisoryDispositionSchema, type CaptureStatus, type CaptureClearResult, type CaptureConfig, @@ -23,6 +36,17 @@ import { type TuningResult, type MpcSimResult, type HierarchicalArea, + type ProfessionalAlarm, + type ConfigurationDiffEntry, + type ConfigurationRevision, + type SystemHealth, + type ProcessOverview, + type ProtectionSnapshot, + type AssetHealthReport, + type ShiftEntry, + type ProductStatus, + type AdvisoryResult, + type AdvisoryDisposition, } from "./schemas"; /** @@ -41,8 +65,195 @@ export function getRouting(): Promise { return apiFetch("/routing"); } -export function deployRouting(payload: unknown): Promise { - return apiFetch("/routing", { method: "POST", json: payload }); +export function createConfigurationDraft( + payload: unknown, + reason: string, +): Promise { + return apiFetch("/configurations/drafts", { + method: "POST", + json: { payload, reason }, + schema: configurationRevisionSchema, + }); +} + +export function getConfigurationRevisions(): Promise { + return apiFetch("/configurations", { schema: configurationRevisionsSchema }); +} + +export function getConfigurationDiff( + revisionId: string, +): Promise { + return apiFetch(`/configurations/${encodeURIComponent(revisionId)}/diff`, { + schema: configurationDiffSchema, + }); +} + +function transitionConfiguration( + revisionId: string, + transition: "validate" | "review" | "activate", +): Promise { + return apiFetch( + `/configurations/${encodeURIComponent(revisionId)}/${transition}`, + { method: "POST", schema: configurationRevisionSchema }, + ); +} + +export const validateConfiguration = (revisionId: string) => + transitionConfiguration(revisionId, "validate"); +export const reviewConfiguration = (revisionId: string) => + transitionConfiguration(revisionId, "review"); +export const activateConfiguration = (revisionId: string) => + transitionConfiguration(revisionId, "activate"); + +export function approveConfiguration( + revisionId: string, + reason: string, +): Promise { + return apiFetch(`/configurations/${encodeURIComponent(revisionId)}/approve`, { + method: "POST", + json: { reason }, + schema: configurationRevisionSchema, + }); +} + +export function rollbackConfiguration( + revisionId: string, + reason: string, +): Promise { + return apiFetch(`/configurations/${encodeURIComponent(revisionId)}/rollback`, { + method: "POST", + json: { reason }, + schema: configurationRevisionSchema, + }); +} + +// --- System identity, health, and recovery ---------------------------------- + +export function getSystemHealth(): Promise { + return apiFetch("/system/health", { schema: systemHealthSchema }); +} + +// --- Representative operator workspace ------------------------------------- + +export function getOperatorOverview(): Promise { + return apiFetch("/operator/overview", { schema: processOverviewSchema }); +} + +export function getProtectionSnapshot(): Promise { + return apiFetch("/operator/protections", { schema: protectionSnapshotSchema }); +} + +export function getRepresentativeAssetHealth(): Promise { + return apiFetch("/operator/assets/health/representative", { + schema: assetHealthReportSchema, + }); +} + +export function getShiftEntries(query = ""): Promise { + return apiFetch(`/operator/shift-log?query=${encodeURIComponent(query)}`, { + schema: shiftEntriesSchema, + }); +} + +export function getProductStatus(): Promise { + return apiFetch("/operator/product-status", { schema: productStatusSchema }); +} + +export function getRepresentativeAdvisory(): Promise { + return apiFetch("/operator/advisories/representative", { + schema: advisoryResultSchema, + }); +} + +export function recordAdvisoryDisposition( + advisoryId: string, + decision: "accepted_for_review" | "rejected" | "deferred", + reason: string, +): Promise { + return apiFetch( + `/operator/advisories/${encodeURIComponent(advisoryId)}/dispositions`, + { + method: "POST", + json: { decision, reason }, + schema: advisoryDispositionSchema, + }, + ); +} + +export function sendProcedureCommand(command: string, reason: string): Promise { + return apiFetch(`/operator/procedure/commands/${encodeURIComponent(command)}`, { + method: "POST", + json: { reason }, + }); +} + +export type RecoveryDownload = { + payload: Blob; + sha256: string; + configurationRevision: string; +}; + +export async function downloadRecoveryPackage(): Promise { + const response = await apiResponse("/system/backups", { method: "POST" }); + const sha256 = response.headers.get("X-Artifact-SHA256"); + const configurationRevision = response.headers.get("X-Configuration-Revision"); + if (!sha256 || !configurationRevision) { + throw new Error("Recovery response omitted identity headers"); + } + return { + payload: await response.blob(), + sha256, + configurationRevision, + }; +} + +export async function restoreRecoveryPackage( + payload: Blob, + sha256: string, + reason: string, +): Promise { + const response = await apiResponse("/system/restores", { + method: "POST", + body: payload, + headers: { + "Content-Type": "application/octet-stream", + "X-Artifact-SHA256": sha256, + "X-Change-Reason": reason, + }, + }); + const parsed = configurationRevisionSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error("Restore response did not match the revision contract"); + } + return parsed.data; +} + +export type EvidenceDownload = { + payload: Blob; + sha256: string; + evidenceId: string; + passed: boolean; +}; + +export async function runRepresentativeScenario(): Promise { + const scenario = await apiFetch("/acceptance/scenarios/representative"); + const response = await apiResponse("/acceptance/scenarios/run", { + method: "POST", + body: JSON.stringify(scenario), + headers: { "Content-Type": "application/json" }, + }); + const sha256 = response.headers.get("X-Artifact-SHA256"); + const evidenceId = response.headers.get("X-Evidence-ID"); + const passed = response.headers.get("X-Evidence-Passed"); + if (!sha256 || !evidenceId || !passed) { + throw new Error("Acceptance response omitted evidence identity headers"); + } + return { + payload: await response.blob(), + sha256, + evidenceId, + passed: passed === "true", + }; } // --- Tags -------------------------------------------------------------------- @@ -79,6 +290,40 @@ export function acknowledgeAlarm(tagId: string): Promise { return apiFetch(`/alarms/${tagId}/acknowledge`, { method: "POST" }); } +export function getProfessionalAlarms(): Promise { + return apiFetch("/alarm-management/active", { + schema: professionalAlarmsSchema, + }); +} + +export function acknowledgeProfessionalAlarm( + tag: string, +): Promise { + return apiFetch(`/alarm-management/${encodeURIComponent(tag)}/acknowledge`, { + method: "POST", + schema: professionalAlarmSchema, + }); +} + +export function shelfProfessionalAlarm( + tag: string, + reason: string, + durationSeconds: number, +): Promise { + return apiFetch(`/alarm-management/${encodeURIComponent(tag)}/shelf`, { + method: "POST", + json: { reason, duration_seconds: durationSeconds }, + schema: professionalAlarmSchema, + }); +} + +export function unshelveProfessionalAlarm(tag: string): Promise { + return apiFetch(`/alarm-management/${encodeURIComponent(tag)}/shelf`, { + method: "DELETE", + schema: professionalAlarmSchema, + }); +} + // --- Alicat mass-flow controllers -------------------------------------------- export function getAlicats(): Promise { diff --git a/src/p1am_control_system/frontend/src/api/schemas.test.ts b/src/p1am_control_system/frontend/src/api/schemas.test.ts index ee9dc06a19..91fac2d4fe 100644 --- a/src/p1am_control_system/frontend/src/api/schemas.test.ts +++ b/src/p1am_control_system/frontend/src/api/schemas.test.ts @@ -72,4 +72,33 @@ describe("telemetryFrameSchema", () => { }); expect(parsed.success).toBe(true); }); + + it("preserves signal quality, timing, diagnostic, source, and sequence", () => { + const parsed = telemetryFrameSchema.safeParse({ + tag_samples: { + TAG_0: { + value: 12.5, + source_timestamp: "2026-08-03T20:00:00+00:00", + server_timestamp: "2026-08-03T20:00:01+00:00", + quality: "stale", + diagnostic_reason: "read_timeout", + sequence: 42, + source: "synthetic.driver", + }, + }, + comms_health: { + quality: "stale", + diagnostic_reason: "read_timeout", + sequence: 42, + server_timestamp: "2026-08-03T20:00:01+00:00", + source: "synthetic.driver", + }, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.tag_samples?.TAG_0.quality).toBe("stale"); + expect(parsed.data.comms_health?.sequence).toBe(42); + } + }); }); diff --git a/src/p1am_control_system/frontend/src/api/schemas.ts b/src/p1am_control_system/frontend/src/api/schemas.ts index ae8c7cc47f..75582c5092 100644 --- a/src/p1am_control_system/frontend/src/api/schemas.ts +++ b/src/p1am_control_system/frontend/src/api/schemas.ts @@ -72,6 +72,332 @@ export type ActiveAlarm = z.infer; export const activeAlarmsSchema = z.array(activeAlarmSchema); +export const signalQualitySchema = z.enum([ + "good", + "uncertain", + "bad", + "stale", + "simulated", +]); + +export const signalSampleSchema = z.object({ + value: z.number(), + source_timestamp: z.string(), + server_timestamp: z.string(), + quality: signalQualitySchema, + diagnostic_reason: z.string().nullable(), + sequence: z.number().int().positive(), + source: z.string().min(1), +}); +export type SignalSample = z.infer; + +export const commsHealthSchema = z.object({ + quality: signalQualitySchema, + diagnostic_reason: z.string().nullable(), + sequence: z.number().int().positive().nullable(), + server_timestamp: z.string().nullable(), + source: z.string().min(1), +}); +export type CommsHealth = z.infer; + +export const professionalAlarmSchema = z.object({ + tag: z.string(), + priority: z.enum(["critical", "high", "medium", "low"]), + lifecycle: z.enum([ + "inactive", + "unacknowledged", + "acknowledged", + "returned_unacknowledged", + "shelved", + "suppressed", + ]), + condition: z.string(), + acknowledged_by: z.string().nullable(), + shelved_by: z.string().nullable(), + shelf_reason: z.string().nullable(), + shelf_until: z.string().nullable(), + suppression_rule: z.string().nullable(), + first_out_sequence: z.number().int().positive().nullable(), + active_since: z.string().nullable(), + help_text: z.string(), +}); +export const professionalAlarmsSchema = z.array(professionalAlarmSchema); +export type ProfessionalAlarm = z.infer; + +export const configurationStateSchema = z.enum([ + "draft", + "validated", + "in_review", + "approved", + "active", + "superseded", +]); +export const configurationRevisionSchema = z.object({ + revision_id: z.string(), + version: z.number().int().positive(), + state: configurationStateSchema, + payload: z.unknown(), + payload_sha256: z.string().regex(/^[0-9a-f]{64}$/), + reason: z.string(), + created_by: z.string(), + created_at: z.string(), + validated_by: z.string().nullable(), + reviewed_by: z.string().nullable(), + approved_by: z.string().nullable(), + activated_by: z.string().nullable(), + activated_at: z.string().nullable(), + activation_identity: z.string().nullable(), + source_revision_id: z.string().nullable(), +}); +export const configurationRevisionsSchema = z.array(configurationRevisionSchema); +export const configurationDiffSchema = z.array( + z.object({ + path: z.string(), + before: z.unknown().nullable(), + after: z.unknown().nullable(), + }), +); +export type ConfigurationRevision = z.infer; +export type ConfigurationDiffEntry = z.infer[number]; + +export const deploymentIdentitySchema = z.object({ + software_revision: z.string(), + configuration_revision: z.string(), + configuration_sha256: z.string().nullable(), + configuration_state: z.string(), +}); +export const systemHealthSchema = z.object({ + generated_at: z.string(), + overall: z.enum(["good", "degraded", "bad"]), + identity: deploymentIdentitySchema, + checks: z.array( + z.object({ + name: z.string(), + status: z.enum(["good", "degraded", "bad"]), + detail: z.string(), + }), + ), +}); +export type DeploymentIdentity = z.infer; +export type SystemHealth = z.infer; + +// --- Representative operator workspace ------------------------------------- + +export const faceplateValueSchema = z.object({ + value: z.number(), + unit: z.string().min(1), + source_timestamp: z.string(), +}); +export const assetFaceplateSchema = z.object({ + asset_id: z.string().startsWith("SYNTHETIC."), + label: z.string(), + asset_type: z.enum(["pump", "valve", "vessel", "heater", "separator"]), + primary_value: faceplateValueSchema, + quality: z.enum(["good", "uncertain", "bad", "stale", "simulated"]), + mode: z.enum(["off", "manual", "automatic", "unavailable"]), + alarm_state: z.enum(["normal", "active", "shelved", "suppressed"]), + interlock_state: z.enum(["clear", "permissive_missing", "tripped"]), + detail_route: z.string(), + trend_tags: z.array(z.string().startsWith("SYNTHETIC.")).min(1), +}); +export const processOverviewSchema = z.object({ + overview_id: z.string().startsWith("SYNTHETIC."), + title: z.string(), + areas: z.array( + z.object({ + area_id: z.string().startsWith("SYNTHETIC."), + label: z.string(), + detail_route: z.string(), + assets: z.array(assetFaceplateSchema), + }), + ), + data_classification: z.literal("synthetic"), + not_for_live_control: z.literal(true), +}); +export const protectionDefinitionSchema = z.object({ + protection_id: z.string().startsWith("SYNTHETIC."), + category: z.enum(["control", "interlock", "independent_protection"]), + consequences: z.array(z.string()).min(1), + bypassable: z.boolean(), +}); +export const tripRecordSchema = z.object({ + protection_id: z.string().startsWith("SYNTHETIC."), + group_id: z.string(), + category: z.enum(["control", "interlock", "independent_protection"]), + consequences: z.array(z.string()), + occurred_at: z.string(), + first_out: z.boolean(), +}); +export const managedBypassSchema = z.object({ + protection_id: z.string().startsWith("SYNTHETIC."), + actor: z.string(), + reason: z.string(), + requested_at: z.string(), + expires_at: z.string(), + banner_required: z.literal(true), + active: z.literal(true), +}); +export const protectionSnapshotSchema = z.object({ + definitions: z.array(protectionDefinitionSchema), + trips: z.array(tripRecordSchema), + active_bypasses: z.array(managedBypassSchema), +}); +export type AssetFaceplate = z.infer; +export type ProcessOverview = z.infer; +export type ProtectionSnapshot = z.infer; + +export const assetHealthReportSchema = z.object({ + asset_id: z.string().startsWith("SYNTHETIC."), + generated_at: z.string(), + counters: z.object({ + runtime_seconds: z.number().nonnegative(), + start_count: z.number().int().nonnegative(), + }), + statistics: z.object({ + sample_count: z.number().int().positive(), + minimum: z.number(), + maximum: z.number(), + mean: z.number(), + standard_deviation: z.number().nonnegative(), + }), + advisories: z.array( + z.object({ + code: z.enum([ + "calibration_due", + "drift", + "flatline", + "command_feedback_mismatch", + "noisy_signal", + ]), + asset_id: z.string().startsWith("SYNTHETIC."), + detected_at: z.string(), + detail: z.string(), + classification: z.literal("maintenance_advisory"), + authoritative_trip: z.literal(false), + }), + ), + data_classification: z.literal("synthetic"), +}); +export const shiftEntrySchema = z.object({ + entry_id: z.string(), + shift_id: z.string().startsWith("SYNTHETIC."), + run_id: z.string().startsWith("SYNTHETIC."), + summary: z.string(), + unresolved_actions: z.array(z.string()), + event_references: z.array( + z.object({ event_id: z.string().startsWith("SYNTHETIC."), occurred_at: z.string() }), + ), + trend_references: z.array( + z.object({ + investigation_id: z.string().startsWith("SYNTHETIC."), + content_sha256: z.string().regex(/^[0-9a-f]{64}$/), + }), + ), + created_by: z.string(), + created_at: z.string(), + data_classification: z.literal("synthetic"), +}); +export const shiftEntriesSchema = z.array(shiftEntrySchema); +export type AssetHealthReport = z.infer; +export type ShiftEntry = z.infer; + +export const productStatusSchema = z.object({ + procedure_state: z.enum([ + "idle", + "starting", + "running", + "holding", + "stopping", + "aborted", + "recovering", + ]), + procedure_events: z.array(z.unknown()), + connectors: z.array( + z.object({ + connector_id: z.string().startsWith("SYNTHETIC."), + version: z.string(), + details: z.record(z.string(), z.unknown()), + }), + ), + samples: z.record( + z.string(), + z.object({ + value: z.number().nullable(), + quality: z.enum(["good", "bad"]), + diagnostic: z.string(), + connector_id: z.string().startsWith("SYNTHETIC."), + }), + ), + notification_policy: z.object({ + primary_recipient: z.string(), + escalation_recipient: z.string(), + }).passthrough(), + notification_audit: z.array(z.unknown()), + availability: z.object({ + recovery_time_objective_seconds: z.number().positive(), + recovery_point_objective_seconds: z.number().positive(), + clock_ordering_reliable: z.boolean(), + command_authority: z.string().nullable(), + transport_available: z.boolean(), + hmi_available: z.boolean(), + buffered_samples: z.number().int().nonnegative(), + data_classification: z.literal("synthetic"), + }), + data_classification: z.literal("synthetic"), + not_for_live_control: z.literal(true), +}); +export type ProductStatus = z.infer; + +const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/); +export const advisoryResultSchema = z.object({ + advisory_id: z.string().startsWith("ADV-"), + generated_at: z.string(), + model: z.object({ + model_id: z.literal("SYNTHETIC.MODEL.ADVISORY"), + version: z.string(), + algorithm: z.string(), + artifact_sha256: sha256Schema, + }), + data: z.object({ + dataset_id: z.string().startsWith("SYNTHETIC."), + content_sha256: sha256Schema, + feature_names: z.array(z.string()), + }), + constraints: z.object({ + minimum: z.number(), + maximum: z.number(), + unit: z.string(), + }), + confidence: z.object({ + level: z.number().gt(0).lt(1), + lower: z.number(), + estimate: z.number(), + upper: z.number(), + }), + recommended_setpoint: z.number(), + recommendation: z.string(), + limitation: z.string(), + replay: z.object({ + input_sha256: sha256Schema, + result_sha256: sha256Schema, + verified: z.literal(true), + }), + authoritative_write_available: z.literal(false), + data_classification: z.literal("synthetic"), + not_for_live_control: z.literal(true), +}); +export type AdvisoryResult = z.infer; + +export const advisoryDispositionSchema = z.object({ + advisory_id: z.string(), + decision: z.enum(["accepted_for_review", "rejected", "deferred"]), + reason: z.string(), + actor: z.string(), + recorded_at: z.string(), + applied_to_control: z.literal(false), +}); +export type AdvisoryDisposition = z.infer; + /** * Live telemetry frame pushed over the `/api/stream` WebSocket. * @@ -87,6 +413,11 @@ export const activeAlarmsSchema = z.array(activeAlarmSchema); export const telemetryFrameSchema = z.object({ tags: z.array(z.number()).optional().catch(undefined), tags_dict: z.record(z.string(), z.number()).optional().catch(undefined), + tag_samples: z + .record(z.string(), signalSampleSchema) + .optional() + .catch(undefined), + comms_health: commsHealthSchema.optional().catch(undefined), alicats: z.array(alicatMfcStateSchema).optional().catch(undefined), active_alarms: z .record(z.string(), activeAlarmSchema) diff --git a/src/p1am_control_system/frontend/src/components/CommsQualityBadge.test.tsx b/src/p1am_control_system/frontend/src/components/CommsQualityBadge.test.tsx new file mode 100644 index 0000000000..af5f9378bc --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/CommsQualityBadge.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CommsQualityBadge } from "./CommsQualityBadge"; + +describe("CommsQualityBadge", () => { + it("does not call a live socket healthy when process data is stale", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("DATA STALE"); + expect(screen.getByRole("status")).toHaveAccessibleDescription( + /read_timeout.*sequence 42/i, + ); + }); + + it("labels simulated data explicitly", () => { + render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("SIMULATED DATA"); + }); + + it("shows transport offline before any quality claim", () => { + render(); + + expect(screen.getByRole("status")).toHaveTextContent("OFFLINE"); + }); +}); diff --git a/src/p1am_control_system/frontend/src/components/CommsQualityBadge.tsx b/src/p1am_control_system/frontend/src/components/CommsQualityBadge.tsx new file mode 100644 index 0000000000..4d1867910c --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/CommsQualityBadge.tsx @@ -0,0 +1,83 @@ +import { useId } from "react"; +import type { CommsHealth } from "../api/schemas"; + +export interface CommsQualityBadgeProps { + transportConnected: boolean; + health: CommsHealth | undefined; +} + +const COLORS: Record = { + good: "var(--color-success)", + uncertain: "var(--color-warning)", + bad: "var(--color-error)", + stale: "var(--color-error)", + simulated: "var(--accent-cyan)", + offline: "var(--color-error)", + waiting: "var(--color-warning)", +}; + +function label( + transportConnected: boolean, + health: CommsHealth | undefined, +): string { + if (!transportConnected) return "OFFLINE"; + if (!health) return "WAITING DATA"; + const labels: Record = { + good: "DATA GOOD", + uncertain: "DATA UNCERTAIN", + bad: "DATA BAD", + stale: "DATA STALE", + simulated: "SIMULATED DATA", + }; + return labels[health.quality]; +} + +function description(health: CommsHealth | undefined): string { + if (!health) return "No qualified process-data frame has arrived."; + const reason = health.diagnostic_reason ?? "no diagnostic reason"; + const sequence = health.sequence === null ? "no sequence" : `sequence ${health.sequence}`; + return `${reason}; ${sequence}; source ${health.source}`; +} + +export function CommsQualityBadge({ + transportConnected, + health, +}: CommsQualityBadgeProps) { + const descriptionId = useId(); + const state = !transportConnected ? "offline" : (health?.quality ?? "waiting"); + return ( + + + {label(transportConnected, health)} + + {description(health)} + + + ); +} diff --git a/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.test.tsx b/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.test.tsx new file mode 100644 index 0000000000..c5e75314cf --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfigurationWorkflowPanel } from "./ConfigurationWorkflowPanel"; +import * as api from "../api/endpoints"; + +vi.mock("../api/endpoints", () => ({ + getConfigurationRevisions: vi.fn(), + getConfigurationDiff: vi.fn(), + validateConfiguration: vi.fn(), + reviewConfiguration: vi.fn(), + approveConfiguration: vi.fn(), + activateConfiguration: vi.fn(), + rollbackConfiguration: vi.fn(), +})); + +const revision = (state: string) => ({ + revision_id: "cfg-000001-aaaaaaaaaaaa", + version: 1, + state, + payload: {}, + payload_sha256: "a".repeat(64), + reason: "Synthetic change", + created_by: "engineer", + created_at: "2026-08-03T00:00:00Z", + validated_by: null, + reviewed_by: null, + approved_by: null, + activated_by: null, + activated_at: null, + activation_identity: null, + source_revision_id: null, +}); + +describe("ConfigurationWorkflowPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getConfigurationDiff).mockResolvedValue([]); + }); + + it("shows immutable revision identity and advances a draft to validation", async () => { + vi.mocked(api.getConfigurationRevisions).mockResolvedValue([revision("draft") as never]); + vi.mocked(api.validateConfiguration).mockResolvedValue(revision("validated") as never); + render(); + + expect(await screen.findByText(/cfg-000001-aaaaaaaaaaaa/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Validate" })); + + await waitFor(() => + expect(api.validateConfiguration).toHaveBeenCalledWith("cfg-000001-aaaaaaaaaaaa"), + ); + }); + + it("requires an explicit review reason before approval", async () => { + vi.mocked(api.getConfigurationRevisions).mockResolvedValue([ + revision("in_review") as never, + ]); + vi.mocked(api.approveConfiguration).mockResolvedValue(revision("approved") as never); + render(); + + fireEvent.change(await screen.findByLabelText(/Review or rollback reason/), { + target: { value: "Synthetic approval evidence" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + + await waitFor(() => + expect(api.approveConfiguration).toHaveBeenCalledWith( + "cfg-000001-aaaaaaaaaaaa", + "Synthetic approval evidence", + ), + ); + }); +}); diff --git a/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.tsx b/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.tsx new file mode 100644 index 0000000000..5e51aa9267 --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/ConfigurationWorkflowPanel.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from "react"; +import type { + ConfigurationDiffEntry, + ConfigurationRevision, +} from "../api/schemas"; +import * as api from "../api/endpoints"; + +const actionLabel: Record = { + draft: "Validate", + validated: "Submit for review", + in_review: "Approve", + approved: "Activate", +}; + +export function ConfigurationWorkflowPanel() { + const [revisions, setRevisions] = useState([]); + const [diff, setDiff] = useState([]); + const [reason, setReason] = useState("Reviewed representative configuration"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const latest = revisions[revisions.length - 1]; + + const refresh = useCallback(async () => { + try { + const next = await api.getConfigurationRevisions(); + setRevisions(next); + const candidate = next[next.length - 1]; + setDiff(candidate ? await api.getConfigurationDiff(candidate.revision_id) : []); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Configuration query failed"); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const advance = async () => { + if (!latest) return; + setBusy(true); + try { + if (latest.state === "draft") { + await api.validateConfiguration(latest.revision_id); + } else if (latest.state === "validated") { + await api.reviewConfiguration(latest.revision_id); + } else if (latest.state === "in_review") { + await api.approveConfiguration(latest.revision_id, reason); + } else if (latest.state === "approved") { + await api.activateConfiguration(latest.revision_id); + } + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Configuration action failed"); + } finally { + setBusy(false); + } + }; + + const rollback = async (revision: ConfigurationRevision) => { + setBusy(true); + try { + await api.rollbackConfiguration(revision.revision_id, reason); + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Rollback failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ Protected Configuration Workflow + +
+

+ Drafts require validation, review, approval, and identified activation. +

+ + {error &&

{error}

} + {!latest ? ( +

No protected revisions yet. Create a draft from an editor.

+ ) : ( + <> +

+ {latest.revision_id} · {latest.state} · SHA-256 {latest.payload_sha256.slice(0, 12)}… +

+

{diff.length} changed configuration fields in the current diff.

+ {actionLabel[latest.state] && ( + + )} + + )} +
+ {revisions + .filter((revision) => revision.state === "superseded") + .slice(-3) + .map((revision) => ( + + ))} +
+
+ ); +} diff --git a/src/p1am_control_system/frontend/src/components/InterlocksPanel.tsx b/src/p1am_control_system/frontend/src/components/InterlocksPanel.tsx index 8dde463455..9947a09ac5 100644 --- a/src/p1am_control_system/frontend/src/components/InterlocksPanel.tsx +++ b/src/p1am_control_system/frontend/src/components/InterlocksPanel.tsx @@ -32,9 +32,9 @@ const InterlocksPanelImpl: React.FC = ({ className="btn btn-primary" style={{ padding: "0.25rem 0.75rem", fontSize: "0.8rem" }} > - {deploying ? "Deploying..." : ( + {deploying ? "Creating Draft..." : ( - Deploy Config + Create Protected Draft )} diff --git a/src/p1am_control_system/frontend/src/components/OperatorWorkspace.test.tsx b/src/p1am_control_system/frontend/src/components/OperatorWorkspace.test.tsx new file mode 100644 index 0000000000..a0299e731c --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/OperatorWorkspace.test.tsx @@ -0,0 +1,229 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as api from "../api/endpoints"; +import { OperatorWorkspace } from "./OperatorWorkspace"; + +vi.mock("../api/endpoints", () => ({ + getOperatorOverview: vi.fn(), + getProtectionSnapshot: vi.fn(), + getRepresentativeAssetHealth: vi.fn(), + getShiftEntries: vi.fn(), + getProductStatus: vi.fn(), + getRepresentativeAdvisory: vi.fn(), + recordAdvisoryDisposition: vi.fn(), +})); + +const overview = { + overview_id: "SYNTHETIC.PROCESS", + title: "Representative Process Overview", + data_classification: "synthetic" as const, + not_for_live_control: true as const, + areas: [ + { + area_id: "SYNTHETIC.FEED", + label: "Feed Preparation", + detail_route: "/operator/areas/SYNTHETIC.FEED", + assets: [ + { + asset_id: "SYNTHETIC.FEED.PUMP", + label: "Feed Pump", + asset_type: "pump" as const, + primary_value: { + value: 62, + unit: "%", + source_timestamp: "2026-08-03T20:00:00Z", + }, + quality: "simulated" as const, + mode: "automatic" as const, + alarm_state: "normal" as const, + interlock_state: "clear" as const, + detail_route: "/operator/assets/SYNTHETIC.FEED.PUMP", + trend_tags: ["SYNTHETIC.FEED.PUMP.PV"], + }, + ], + }, + { + area_id: "SYNTHETIC.REACTOR", + label: "Reaction", + detail_route: "/operator/areas/SYNTHETIC.REACTOR", + assets: [], + }, + { + area_id: "SYNTHETIC.SEPARATION", + label: "Separation", + detail_route: "/operator/areas/SYNTHETIC.SEPARATION", + assets: [], + }, + ], +}; + +const protections = { + definitions: [ + { + protection_id: "SYNTHETIC.REACTOR.HIGH_PRESSURE", + category: "interlock" as const, + consequences: ["SYNTHETIC.FEED stops"], + bypassable: true, + }, + { + protection_id: "SYNTHETIC.REACTOR.INDEPENDENT_TRIP", + category: "independent_protection" as const, + consequences: ["Synthetic heater power removed"], + bypassable: false, + }, + ], + trips: [ + { + protection_id: "SYNTHETIC.REACTOR.HIGH_PRESSURE", + group_id: "trip-1", + category: "interlock" as const, + consequences: ["SYNTHETIC.FEED stops"], + occurred_at: "2026-08-03T20:00:00Z", + first_out: true, + }, + ], + active_bypasses: [ + { + protection_id: "SYNTHETIC.REACTOR.HIGH_PRESSURE", + actor: "engineer", + reason: "Synthetic FAT verification", + requested_at: "2026-08-03T20:00:00Z", + expires_at: "2026-08-03T21:00:00Z", + banner_required: true as const, + active: true as const, + }, + ], +}; + +const assetHealth = { + asset_id: "SYNTHETIC.FEED.PUMP", + generated_at: "2026-08-03T20:00:00Z", + counters: { runtime_seconds: 600, start_count: 1 }, + statistics: { + sample_count: 2, + minimum: 15, + maximum: 15, + mean: 15, + standard_deviation: 0, + }, + advisories: [ + { + code: "calibration_due" as const, + asset_id: "SYNTHETIC.FEED.PUMP", + detected_at: "2026-08-03T20:00:00Z", + detail: "Calibration due date has passed", + classification: "maintenance_advisory" as const, + authoritative_trip: false as const, + }, + ], + data_classification: "synthetic" as const, +}; + +const productStatus = { + procedure_state: "idle" as const, + procedure_events: [], + connectors: [ + { + connector_id: "SYNTHETIC.CONNECTOR.DEMO", + version: "1.0", + details: { state: "online" }, + }, + ], + samples: { + "SYNTHETIC.DEMO.PV": { + value: 1, + quality: "good" as const, + diagnostic: "", + connector_id: "SYNTHETIC.CONNECTOR.DEMO", + }, + }, + notification_policy: { + primary_recipient: "synthetic.primary", + escalation_recipient: "synthetic.escalation", + }, + notification_audit: [], + availability: { + recovery_time_objective_seconds: 300, + recovery_point_objective_seconds: 30, + clock_ordering_reliable: true, + command_authority: null, + transport_available: true, + hmi_available: true, + buffered_samples: 0, + data_classification: "synthetic" as const, + }, + data_classification: "synthetic" as const, + not_for_live_control: true as const, +}; + +const representativeAdvisory = { + advisory_id: "ADV-0123456789abcdef", + generated_at: "2026-08-03T21:00:00Z", + model: { + model_id: "SYNTHETIC.MODEL.ADVISORY" as const, + version: "1.0.0", + algorithm: "representative bounded linear projection", + artifact_sha256: "a".repeat(64), + }, + data: { + dataset_id: "SYNTHETIC.DATASET.REPRESENTATIVE-RUN", + content_sha256: "b".repeat(64), + feature_names: ["observed_throughput", "observed_energy", "requested_throughput"], + }, + constraints: { minimum: 40, maximum: 80, unit: "synthetic energy index" }, + confidence: { level: 0.9, lower: 46.6, estimate: 49.1, upper: 51.6 }, + recommended_setpoint: 49.1, + recommendation: "Review bounded synthetic setpoint in scenario", + limitation: "Representative linear projection only; unable to issue commands.", + replay: { input_sha256: "c".repeat(64), result_sha256: "d".repeat(64), verified: true as const }, + authoritative_write_available: false as const, + data_classification: "synthetic" as const, + not_for_live_control: true as const, +}; + +describe("OperatorWorkspace", () => { + beforeEach(() => { + vi.mocked(api.getOperatorOverview).mockResolvedValue(overview); + vi.mocked(api.getProtectionSnapshot).mockResolvedValue(protections); + vi.mocked(api.getRepresentativeAssetHealth).mockResolvedValue(assetHealth); + vi.mocked(api.getShiftEntries).mockResolvedValue([]); + vi.mocked(api.getProductStatus).mockResolvedValue(productStatus); + vi.mocked(api.getRepresentativeAdvisory).mockResolvedValue(representativeAdvisory); + }); + + it("navigates from a multi-area overview to a consistent faceplate", async () => { + render(); + + expect(await screen.findByText("Feed Preparation")).toBeInTheDocument(); + expect(screen.getByText("Reaction")).toBeInTheDocument(); + expect(screen.getByText("Separation")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Feed Pump/ })); + + expect(screen.getByRole("dialog", { name: "Feed Pump faceplate" })).toHaveTextContent( + "Quality simulated", + ); + expect(screen.getByRole("dialog")).toHaveTextContent("Mode automatic"); + expect(screen.getByRole("dialog")).toHaveTextContent("Alarm normal"); + expect(screen.getByRole("dialog")).toHaveTextContent("Interlock clear"); + expect(screen.getByRole("button", { name: "Open trend drill-down" })).toBeInTheDocument(); + }); + + it("keeps protection categories and active bypass status unmistakable", async () => { + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Synthetic FAT verification"); + expect(screen.getByText("FIRST OUT")).toBeInTheDocument(); + expect(screen.getByText("interlock", { selector: "strong" })).toBeInTheDocument(); + expect(screen.getByText("independent protection", { selector: "strong" })).toBeInTheDocument(); + expect(screen.getByText("Non-bypassable")).toBeInTheDocument(); + expect(screen.getByText(/calibration due date has passed/i)).toBeInTheDocument(); + expect(screen.getByText(/Saved synthetic investigations retain/i)).toBeInTheDocument(); + expect(screen.getByText(/Signed entries are append-only/i)).toBeInTheDocument(); + expect(screen.getByText(/Procedure state:/i)).toHaveTextContent("idle"); + expect(screen.getByText(/RTO 300s \/ RPO 30s/i)).toBeInTheDocument(); + expect(screen.getByText(/Advisory optimization & digital twin/i)).toBeInTheDocument(); + expect(screen.getByText(/90% confidence: 46.6–51.6/i)).toBeInTheDocument(); + expect(screen.getByText(/No authoritative write path/i)).toBeInTheDocument(); + }); +}); diff --git a/src/p1am_control_system/frontend/src/components/OperatorWorkspace.tsx b/src/p1am_control_system/frontend/src/components/OperatorWorkspace.tsx new file mode 100644 index 0000000000..e42076e1c3 --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/OperatorWorkspace.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from "react"; +import { + getOperatorOverview, + getProtectionSnapshot, + getRepresentativeAssetHealth, + getShiftEntries, + getProductStatus, + getRepresentativeAdvisory, + recordAdvisoryDisposition, +} from "../api/endpoints"; +import type { + AssetFaceplate, + AssetHealthReport, + ProcessOverview, + ProtectionSnapshot, + ProductStatus, + AdvisoryResult, + ShiftEntry, +} from "../api/schemas"; + +const cardStyle = { + border: "1px solid var(--panel-border)", + borderRadius: "0.65rem", + background: "var(--panel-bg)", + padding: "0.8rem", +} as const; + +function Faceplate({ asset, onClose }: { asset: AssetFaceplate; onClose: () => void }) { + return ( +
+
+
+ {asset.label} +
{asset.asset_id}
+
+ +
+

+ {asset.primary_value.value} {asset.primary_value.unit} +

+
+
Quality {asset.quality}
+
Mode {asset.mode}
+
Alarm {asset.alarm_state}
+
Interlock {asset.interlock_state}
+
+ +
+ ); +} + +function ProtectionView({ snapshot }: { snapshot: ProtectionSnapshot }) { + return ( +
+

Protection, permissive, and first-out context

+ {snapshot.active_bypasses.map((bypass) => ( +
+ ACTIVE MANAGED BYPASS — {bypass.protection_id}: {bypass.reason}. Expires {bypass.expires_at}. +
+ ))} +
+ {snapshot.definitions.map((definition) => { + const trip = snapshot.trips.find((item) => item.protection_id === definition.protection_id); + return ( +
+ {definition.category.replace("_", " ")} + {trip?.first_out &&
FIRST OUT
} +
{definition.protection_id}
+
    {definition.consequences.map((item) =>
  • {item}
  • )}
+ {!definition.bypassable && Non-bypassable} +
+ ); + })} +
+
+ ); +} + +export function OperatorWorkspace() { + const [overview, setOverview] = useState(null); + const [protections, setProtections] = useState(null); + const [selected, setSelected] = useState(null); + const [assetHealth, setAssetHealth] = useState(null); + const [shiftEntries, setShiftEntries] = useState([]); + const [productStatus, setProductStatus] = useState(null); + const [advisory, setAdvisory] = useState(null); + const [dispositionStatus, setDispositionStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + Promise.all([ + getOperatorOverview(), + getProtectionSnapshot(), + getRepresentativeAssetHealth(), + getShiftEntries(), + getProductStatus(), + getRepresentativeAdvisory(), + ]) + .then(([nextOverview, nextProtections, nextHealth, nextEntries, nextProduct, nextAdvisory]) => { + if (active) { + setOverview(nextOverview); + setProtections(nextProtections); + setAssetHealth(nextHealth); + setShiftEntries(nextEntries); + setProductStatus(nextProduct); + setAdvisory(nextAdvisory); + } + }) + .catch((reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : "Operator workspace unavailable"); + }); + return () => { active = false; }; + }, []); + + if (error) return
{error}
; + if (!overview || !protections || !assetHealth || !productStatus || !advisory) return
Loading representative operator workspace…
; + + const disposition = async ( + decision: "accepted_for_review" | "rejected" | "deferred", + ) => { + try { + const record = await recordAdvisoryDisposition( + advisory.advisory_id, + decision, + "Operator disposition from representative advisory workspace", + ); + setDispositionStatus(`${record.decision.replace(/_/g, " ")} by ${record.actor}; no control value applied.`); + } catch (reason: unknown) { + setDispositionStatus(reason instanceof Error ? reason.message : "Disposition failed"); + } + }; + + return ( +
+
+

{overview.title}

+

Synthetic demonstration only. Not for live control.

+
+
+ {overview.areas.map((area) => ( +
+

{area.label}

+ {area.assets.map((asset) => ( + + ))} +
+ ))} +
+ +
+

Asset health & maintenance

+

+ {assetHealth.asset_id}: {assetHealth.counters.runtime_seconds} runtime seconds, {assetHealth.counters.start_count} starts. + Advisories are maintenance records, never authoritative trips. +

+
    + {assetHealth.advisories.map((advisory) => ( +
  • {advisory.code.replace(/_/g, " ")}: {advisory.detail}
  • + ))} +
+
+
+

Investigations & reporting

+

+ Saved synthetic investigations retain query bounds, tag metadata, transformations, + charts, annotations, event context, explicit bad-data handling, and export checksums. +

+
+
+

Shift log & handover

+ {shiftEntries.length === 0 ? ( +

No synthetic handover entries.

+ ) : ( +
    {shiftEntries.map((entry) =>
  • {entry.summary}
  • )}
+ )} +

Signed entries are append-only; receiving operators acknowledge unresolved work explicitly.

+
+
+

Reusable control product

+

Procedure state: {productStatus.procedure_state}. Simulator-only transitions are bounded and attributable.

+
    + {productStatus.connectors.map((connector) => { + const samples = Object.values(productStatus.samples).filter( + (sample) => sample.connector_id === connector.connector_id, + ); + const quality = samples.some((sample) => sample.quality === "bad") ? "bad" : "good"; + return
  • {connector.connector_id}: {quality}
  • ; + })} +
+

+ Notifications escalate from {productStatus.notification_policy.primary_recipient} to {productStatus.notification_policy.escalation_recipient}; deliveries are delayed, suppressed, rate-limited, redacted, and audited. +

+

+ Recovery objectives: RTO {productStatus.availability.recovery_time_objective_seconds}s / RPO {productStatus.availability.recovery_point_objective_seconds}s. One command authority; energizing commands fail closed without the HMI. +

+
+
+

Advisory optimization & digital twin

+

Review only. No authoritative write path. Synthetic demonstration; not validated against a plant.

+

+ Model {advisory.model.model_id} v{advisory.model.version}; dataset {advisory.data.dataset_id}. +

+

+ Recommendation: {advisory.recommended_setpoint} {advisory.constraints.unit} within {advisory.constraints.minimum}–{advisory.constraints.maximum}. + {" "}{advisory.confidence.level * 100}% confidence: {advisory.confidence.lower}–{advisory.confidence.upper}. +

+

Replay verified: {String(advisory.replay.verified)}; result checksum {advisory.replay.result_sha256.slice(0, 12)}…

+
+ + + +
+ {dispositionStatus &&

{dispositionStatus}

} +
+ {selected && setSelected(null)} />} +
+ ); +} diff --git a/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.test.tsx b/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.test.tsx new file mode 100644 index 0000000000..fbeb8f225e --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProfessionalAlarmPanel } from "./ProfessionalAlarmPanel"; +import * as api from "../api/endpoints"; + +vi.mock("../api/endpoints", () => ({ + getProfessionalAlarms: vi.fn(), + acknowledgeProfessionalAlarm: vi.fn(), + shelfProfessionalAlarm: vi.fn(), + unshelveProfessionalAlarm: vi.fn(), +})); + +const alarm = { + tag: "TAG_0", + priority: "high" as const, + lifecycle: "unacknowledged" as const, + condition: "high", + acknowledged_by: null, + shelved_by: null, + shelf_reason: null, + shelf_until: null, + suppression_rule: null, + first_out_sequence: 1, + active_since: "2026-08-03T20:00:00Z", + help_text: "Review signal quality and the generic process context.", +}; + +describe("ProfessionalAlarmPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getProfessionalAlarms).mockResolvedValue([alarm]); + vi.mocked(api.acknowledgeProfessionalAlarm).mockResolvedValue(alarm); + }); + + it("shows priority, lifecycle, first-out, and response guidance", async () => { + render(); + + expect(await screen.findByText("TAG_0")).toBeInTheDocument(); + expect(screen.getByText(/high · unacknowledged/i)).toBeInTheDocument(); + expect(screen.getByText(/first-out #1/i)).toBeInTheDocument(); + expect(screen.getByText(/Review signal quality/)).toBeInTheDocument(); + }); + + it("acknowledges through the canonical API and refreshes", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: /acknowledge TAG_0/i })); + + await waitFor(() => + expect(api.acknowledgeProfessionalAlarm).toHaveBeenCalledWith("TAG_0"), + ); + expect(api.getProfessionalAlarms).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.tsx b/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.tsx new file mode 100644 index 0000000000..9c31deb31a --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/ProfessionalAlarmPanel.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ProfessionalAlarm } from "../api/schemas"; +import * as api from "../api/endpoints"; + +export function ProfessionalAlarmPanel() { + const [alarms, setAlarms] = useState([]); + const [reason, setReason] = useState("Synthetic maintenance"); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setAlarms(await api.getProfessionalAlarms()); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Alarm query failed"); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const mutate = async (operation: () => Promise) => { + try { + await operation(); + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Alarm action failed"); + } + }; + + return ( +
+
+ Professional Alarm Lifecycle + + Supervisory demonstration — not independent protection + +
+ + {error &&

{error}

} + {alarms.length === 0 ? ( +

No active lifecycle alarms.

+ ) : ( +
+ {alarms.map((alarm) => ( +
+
+ {alarm.tag} + {alarm.priority} · {alarm.lifecycle} +
+

Condition: {alarm.condition}

+

{alarm.first_out_sequence ? `First-out #${alarm.first_out_sequence}` : "No first-out order"}

+

{alarm.help_text}

+
+ + + {alarm.lifecycle === "shelved" && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/p1am_control_system/frontend/src/components/SystemHealthPanel.test.tsx b/src/p1am_control_system/frontend/src/components/SystemHealthPanel.test.tsx new file mode 100644 index 0000000000..c5fcb8813c --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/SystemHealthPanel.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemHealthPanel } from "./SystemHealthPanel"; +import * as api from "../api/endpoints"; + +vi.mock("../api/endpoints", () => ({ + getSystemHealth: vi.fn(), + downloadRecoveryPackage: vi.fn(), + restoreRecoveryPackage: vi.fn(), + runRepresentativeScenario: vi.fn(), +})); + +const health = { + generated_at: "2026-08-03T00:00:00Z", + overall: "degraded" as const, + identity: { + software_revision: "software-test-1", + configuration_revision: "cfg-000001-proof", + configuration_sha256: "a".repeat(64), + configuration_state: "active", + }, + checks: [{ name: "primary_transport", status: "degraded" as const, detail: "Disconnected" }], +}; + +describe("SystemHealthPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(api.getSystemHealth).mockResolvedValue(health); + }); + + it("shows deployment identity without conflating degraded transport", async () => { + render(); + + expect(await screen.findByText(/software-test-1/)).toBeInTheDocument(); + expect(screen.getByText(/primary_transport: degraded/)).toBeInTheDocument(); + expect(screen.getByText(/restore into a draft only/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Run Synthetic Acceptance Scenario" }), + ).toBeInTheDocument(); + }); + + it("refuses restore without a package and checksum", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Verify & Restore as Draft" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Select a package"); + await waitFor(() => expect(api.restoreRecoveryPackage).not.toHaveBeenCalled()); + }); +}); diff --git a/src/p1am_control_system/frontend/src/components/SystemHealthPanel.tsx b/src/p1am_control_system/frontend/src/components/SystemHealthPanel.tsx new file mode 100644 index 0000000000..dfc6aecd0d --- /dev/null +++ b/src/p1am_control_system/frontend/src/components/SystemHealthPanel.tsx @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useState } from "react"; +import type { SystemHealth } from "../api/schemas"; +import * as api from "../api/endpoints"; + +export function SystemHealthPanel() { + const [health, setHealth] = useState(null); + const [file, setFile] = useState(null); + const [checksum, setChecksum] = useState(""); + const [reason, setReason] = useState("Synthetic recovery exercise"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setHealth(await api.getSystemHealth()); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Health query failed"); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const backup = async () => { + setBusy(true); + try { + const artifact = await api.downloadRecoveryPackage(); + setChecksum(artifact.sha256); + const url = URL.createObjectURL(artifact.payload); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `p1am-recovery-${artifact.configurationRevision}.zip`; + anchor.click(); + URL.revokeObjectURL(url); + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Backup failed"); + } finally { + setBusy(false); + } + }; + + const restore = async () => { + if (!file || !checksum.trim()) { + setError("Select a package and provide its SHA-256 checksum"); + return; + } + setBusy(true); + try { + await api.restoreRecoveryPackage(file, checksum.trim(), reason); + setError(null); + await refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Restore failed"); + } finally { + setBusy(false); + } + }; + + const runAcceptance = async () => { + setBusy(true); + try { + const artifact = await api.runRepresentativeScenario(); + const url = URL.createObjectURL(artifact.payload); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${artifact.evidenceId}.zip`; + anchor.click(); + URL.revokeObjectURL(url); + setError(artifact.passed ? null : "Scenario completed with failed evidence"); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Scenario run failed"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ System Health & Recovery + +
+ {error &&

{error}

} + {health && ( + <> +

+ Overall: {health.overall} · software {health.identity.software_revision} · configuration {health.identity.configuration_revision} +

+
    + {health.checks.map((check) => ( +
  • {check.name}: {check.status} — {check.detail}
  • + ))} +
+ + )} +

+ Recovery packages exclude energized state and restore into a draft only. +

+ + +
+ + + + +
+
+ ); +} diff --git a/src/p1am_control_system/frontend/src/components/TagInspector.tsx b/src/p1am_control_system/frontend/src/components/TagInspector.tsx index 3a1ea33852..2cadb9cf60 100644 --- a/src/p1am_control_system/frontend/src/components/TagInspector.tsx +++ b/src/p1am_control_system/frontend/src/components/TagInspector.tsx @@ -337,7 +337,7 @@ export const TagInspector: React.FC<{ marginTop: "0.5rem", }} > - {deploying ? "Deploying Configuration..." : "Commit Safety Limits"} + {deploying ? "Creating Draft..." : "Create Protected Limits Draft"} )} diff --git a/src/p1am_control_system/frontend/src/help/helpContent.ts b/src/p1am_control_system/frontend/src/help/helpContent.ts index aa09f80db2..42af04aa50 100644 --- a/src/p1am_control_system/frontend/src/help/helpContent.ts +++ b/src/p1am_control_system/frontend/src/help/helpContent.ts @@ -30,6 +30,33 @@ live data to this browser HMI over a WebSocket. in/out (power-supply monitor and command). Full details are in \`USER_MANUAL.md\`.`; export const HELP: Record = { + operator: { + title: "Representative Operator Overview", + body: `A **synthetic, non-live-control** workspace demonstrating professional +overview-to-detail navigation without plant names, parameters, or control logic. + +### Navigation and state +- Select a generic asset to open its reusable faceplate with value, quality, +mode, alarm, interlock, and trend-drill-down context. +- Protection cards keep control, interlock, and independent-protection +categories distinct and show deterministic first-out consequences. +- Any managed bypass is displayed in a persistent banner with actor, reason, +and expiry. Items marked **Non-bypassable** cannot be bypassed through this UI. + +### Reusable product demonstrations +- Simulator-only procedures expose bounded start, run, hold, stop, abort, and +recovery states with attributable transitions. +- Connector health identifies the responsible plugin; a failed connector +degrades only its own tags and all failed commands are rejected closed. +- Notification and recovery summaries show escalation recipients, delivery +controls, single command authority, clock reliability, and explicit RTO/RPO. +- The advisory workspace identifies the synthetic model and data, shows bounded +constraints and confidence, verifies replay checksums, and records attributable +operator review dispositions. It has no authoritative command or write path. +These are representative contracts, not claims of deployed redundant hardware, +validated plant models, or approved advanced control.`, + }, + temperature: { title: "Heater Controls", body: `Controls the **110 V resistive crucible heater** through a single 24 V diff --git a/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.test.ts b/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.test.ts index 2c05544e3f..af6af90d15 100644 --- a/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.test.ts +++ b/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.test.ts @@ -144,6 +144,28 @@ describe("useTelemetryStream", () => { expect(result.current.temperatureStatus).toBe(temp); }); + it("surfaces stale communications independently of transport connectivity", () => { + const { result } = renderHook(() => useTelemetryStream()); + + act(() => { + MockWebSocket.instances[0].onmessage?.({ + data: JSON.stringify({ + comms_health: { + quality: "stale", + diagnostic_reason: "read_timeout", + sequence: 9, + server_timestamp: "2026-08-03T20:00:00+00:00", + source: "synthetic.driver", + }, + }), + }); + }); + + expect(result.current.isConnected).toBe(true); + expect(result.current.commsHealth?.quality).toBe("stale"); + expect(result.current.commsHealth?.diagnostic_reason).toBe("read_timeout"); + }); + it("bounds the live history buffer (MAX_HISTORY eviction)", () => { const { result } = renderHook(() => useTelemetryStream()); const ws = MockWebSocket.instances[0]; diff --git a/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.ts b/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.ts index 300d15a57a..5ac993b079 100644 --- a/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.ts +++ b/src/p1am_control_system/frontend/src/hooks/useTelemetryStream.ts @@ -4,7 +4,7 @@ import { SAMPLES_PER_SECOND } from "../lib/trendTime"; import { telemetryFrameSchema } from "../api/schemas"; import type { PowerSupplyStatus } from "../components/PowerSupplyControl"; import type { TemperatureStatus } from "../components/TemperatureControl"; -import type { AlicatMFCState, ActiveAlarm } from "../api/schemas"; +import type { AlicatMFCState, ActiveAlarm, CommsHealth } from "../api/schemas"; /** * Live telemetry from the `/api/stream` WebSocket (#3543). @@ -36,6 +36,7 @@ export interface TelemetryState { eStopActive: boolean; powerSupplyStatus: PowerSupplyStatus | undefined; temperatureStatus: TemperatureStatus | undefined; + commsHealth: CommsHealth | undefined; isConnected: boolean; } @@ -116,6 +117,9 @@ export function useTelemetryStream( const [temperatureStatus, setTemperatureStatus] = useState< TemperatureStatus | undefined >(undefined); + const [commsHealth, setCommsHealth] = useState( + undefined, + ); const [isConnected, setIsConnected] = useState(false); const wsRef = useRef(null); @@ -209,6 +213,17 @@ export function useTelemetryStream( : next, ); } + if (frame.comms_health) { + const next = frame.comms_health; + setCommsHealth((prev) => + shallowObjEqual( + prev as unknown as Record | undefined, + next as unknown as Record, + ) + ? prev + : next, + ); + } lastFrameAt = Date.now(); setIsConnected(true); return true; @@ -289,6 +304,7 @@ export function useTelemetryStream( eStopActive, powerSupplyStatus, temperatureStatus, + commsHealth, isConnected, setAlicats, setActiveAlarms, diff --git a/src/p1am_control_system/frontend/src/lib/tabs.ts b/src/p1am_control_system/frontend/src/lib/tabs.ts index fcaf0e0ae4..0ed613ca65 100644 --- a/src/p1am_control_system/frontend/src/lib/tabs.ts +++ b/src/p1am_control_system/frontend/src/lib/tabs.ts @@ -10,6 +10,7 @@ */ export type TabId = + | "operator" | "trends" | "explorer" | "controllers" @@ -34,6 +35,12 @@ export interface TabDef { } export const TABS: readonly TabDef[] = [ + { + id: "operator", + label: "Operator Overview", + settingsLabel: "Representative Operator Workspace", + accentVar: "var(--accent-cyan)", + }, { id: "trends", label: "Trends & Monitors", diff --git a/src/p1am_control_system/frontend/src/test/setup.ts b/src/p1am_control_system/frontend/src/test/setup.ts index c5244a87a1..9785f954f1 100644 --- a/src/p1am_control_system/frontend/src/test/setup.ts +++ b/src/p1am_control_system/frontend/src/test/setup.ts @@ -2,6 +2,37 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +// Node 25 exposes an incomplete global localStorage unless it receives a +// backing-file option. Install an isolated standards-shaped test store when +// that host object is unusable; production browsers retain their native store. +if ( + typeof window !== "undefined" && + (typeof globalThis.localStorage?.getItem !== "function" || + typeof globalThis.localStorage?.clear !== "function") +) { + const values = new Map(); + const testStorage: Storage = { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => { + values.delete(key); + }, + setItem: (key, value) => { + values.set(key, String(value)); + }, + }; + const descriptor = { + configurable: true, + value: testStorage, + }; + Object.defineProperty(globalThis, "localStorage", descriptor); + Object.defineProperty(window, "localStorage", descriptor); +} + // jsdom does not implement PointerEvent, so React's synthetic onPointerDown/ // Move/Up/Leave never fire under fireEvent.pointer*. Alias it to MouseEvent // (which carries clientX/clientY) so the pointer-driven trend interactions —