Skip to content

ADR 0043: NE 107 device health is polled from a declared address into ControlModule status and annunciated through declared alarms; the instrument nameplate lives on the ControlModule

Status: Accepted Date: 2026-08-09 Issue: #935

Context

Every PA-DIM device carries a mandatory 2:DeviceHealth variable — the NAMUR NE 107 self-diagnosis of the instrument itself (OPC 30081 §7.2, via OPC UA DI's IDeviceHealthType, OPC 10000-100 §4.12): NORMAL, FAILURE, CHECK_FUNCTION, OFF_SPEC, MAINTENANCE_REQUIRED. This is the device's verdict about its own fitness, distinct from the process value it measures — a transmitter can report a perfectly plausible flow while asking for maintenance, and an operator who cannot see that trusts a reading the instrument itself has flagged.

The #935 ingestion (slice 1) reads a PA-DIM device's declared signal model into role-declared ControlModule tags and reports that DeviceHealth exists, but nothing monitors it: no NE 107 vocabulary exists anywhere in the codebase, AlarmConditionType has no way to match an enumeration, and ControlModule status has no health field. Device health is also not infrastructure health: ADR 0037 settled that server/infrastructure state reaches the operator only as interpreted alarms on the console, and an instrument's NE 107 state is equipment scope — it belongs on the equipment surfaces.

The same slice left the instrument's identity homeless. A PA-DIM device publishes a mandatory IEC 61987 nameplate (manufacturer, model, serial number, revisions), but IOModule.spec.fieldDevice is a single record describing the one box the IOModule addresses. When one OPC UA endpoint hosts several PA-DIM instruments, the endpoint's IOModule cannot carry their nameplates, and FieldDeviceRecord's own doc comment defers the fuller nameplate to this ADR.

Decision

The ingestion wizard declares the DeviceHealth address on the ControlModule. The namespace io-probe polls it and the control operator writes the value verbatim into status.deviceHealth, with Unknown always carrying a reason that distinguishes a failed read from an absent report. Annunciation is an ordinary declared AlarmDefinition through a new DeviceHealth condition type — nothing is auto-minted. The instrument nameplate becomes ControlModule.spec.fieldDevice.

  • The address is a declaration on the CR. A recognized PA-DIM device's ControlModule gains an optional spec.deviceHealth { address, ioModuleRef } block, written by the discovery wizard at emit time — the same shape as a tag address, the same declared-hook pattern as ChannelSpec.diagnosticAddress. No component ever goes looking for health on its own; a CM without the block has no device-health surface at all.
  • The io-probe performs the read; the control operator owns the cadence. PA-DIM instruments are network-reached and own no compute node (ADR 0021), and the io-probe already holds the endpoint-connectivity domain and the placement machinery to reach zoned field networks (ADR 0042). The control operator's ControlModule reconciler polls on a periodic requeue (default 30s), reading the declared address through the probe's existing read API (<ioModuleRef>:<address>), and writes status. Demand-driven rather than probe-cached — the probe's config schema and serving surface stay untouched, and the observable behavior (cadence, failure semantics) is identical.
  • Status is served verbatim, and absence is not a state. status.deviceHealth is an enum: Normal | Failure | CheckFunction | OffSpec | MaintenanceRequired | Unknown. status.deviceHealthReason qualifies Unknown: ReadFailed (the probe cannot reach or read the node), NotReported (the device answered with no value), UnrecognizedValue (a numeric outside the pinned enumeration — forward compatibility with future DI revisions). A CM that never declared spec.deviceHealth carries no status field whatsoever: an unconfigured module must never be confusable with an unhealthy or unreadable one (the #1324/#1313 rule — a blind channel and an unknown machine must not return the same value, and an absent marker must not read as a state). The numeric enumeration values are pinned from the official DI NodeSet at implementation time, never from memory.
  • Annunciation is declared, per deployment. AlarmDefinition gains condition type DeviceHealth with a healthIn: match list (e.g. [Failure], or [OffSpec, MaintenanceRequired]), source-bound to a ControlModule like every other condition. Which states alarm, and at what severity, is a site decision written as reviewable alarm-policy lines — FAILURE → Critical and MAINTENANCE_REQUIRED → Low in the examples, mirroring DI's own FailureAlarmType / MaintenanceRequiredAlarmType taxonomy without hard-coding it. The platform mints no alarm on its own.
  • The HMI renders the served field. The faceplate gains a DEVICE HEALTH row showing status.deviceHealth exactly as served (ADR 0016: a dumb renderer over declared fields). The row appears only when the field is present. Alarm annunciation rides the alarm list the operator already watches; the ADR 0037 boundary holds because this is equipment health on equipment surfaces, not an infrastructure feed.
  • NE 107's own color vocabulary enters the palette as dedicated tokens. Normal and Failure render with the reserved health pair (--state-running / --state-stopped) — a device-health verdict is precisely what that reservation exists for. Check Function (orange), Out of Specification (yellow) and Maintenance Required (blue) get three new --ne107-* tokens: the colors are NAMUR-standardized and operators know them from every vendor's device-management screen, and no existing token carries them honestly — --state-aborted's orange means abnormal termination and --state-complete's blue means done/released, and repainting either here would overload the very semantics the color doctrine protects. The tokens are documented in ui-color-guide.md and classified in the color snapshot, so lint-colors review covers them like every reserved use.
  • The nameplate lands on the ControlModule. The CM is the instrument's counterpart object, so ControlModuleSpec gains an optional fieldDevice *FieldDeviceRecord, and FieldDeviceRecord gains an optional bounded serialNumber. The wizard writes type: instrument plus vendor/model/firmware/serial from the IEC 61987 nameplate. The IOModule keeps its single record for the box it addresses (unchanged from ADR 0033's classification-by-declared-type); the slice-1 behavior of stamping the endpoint IOModule when it hosts exactly one instrument remains valid and unchanged.

Alternatives Considered

  • Model health as a tag with role: alarm. Rejected. Tag roles are process semantics (ADR 0016) and the historian/trend path treats tags as process data; an NE 107 enum is device diagnostics, not a process signal, and forcing it through the tag vocabulary would pollute both the role set and the trend surfaces.
  • Subscribe to DeviceHealthAlarms (OPC UA Alarms & Conditions). Deferred. The alarms folder is optional on the device while the enum variable is mandatory, the codebase has no A&C event client, and the platform's posture is poll-backed state with subscriptions as accelerators only (ADR 0028/0029). Polling the mandatory variable covers every conformant device; an A&C accelerator can be added later without changing this surface.
  • Poll from the unit-runtime. Rejected. Network instruments own no compute node (ADR 0021), and the unit-runtime executes a unit's FB network — it is not present for, or scoped to, per-device diagnostics on shared network endpoints.
  • Poll from the gateway. Rejected. The gateway is a stateless request-scoped HTTP server with no reconcile loop; giving it a background poll would create a second, unmanaged controller.
  • Auto-mint alarms for non-Normal states. Rejected. Severity and alarm-worthiness are site decisions (a lab skid may live in MAINTENANCE_REQUIRED for a week on purpose); baking a policy into the platform removes the reviewable declaration the alarm machinery is built around.
  • Grow IOModule.spec.fieldDevice into a list. Rejected. The IOModule models the box it addresses — for a multi-instrument endpoint that box is some aggregator the record cannot classify without guessing, and instrument identity duplicated on the transport object drifts from the instrument's own CM.

Consequences

  • The io-probe reads declared non-tag nodes for the first time — through its existing read API, unchanged. The Unknown-outcome counter (dcs_device_health_unknown_total, by reason) lives with the poll owner in the control operator (documented in monitoring-metrics.md, gated by lint-docs-metrics).
  • The control operator writes ControlModule status on a periodic requeue; a deleted or never-declared block writes nothing and clears any previously-written field on the next pass.
  • All CRD changes are additive and optional: spec.deviceHealth, status.deviceHealth + status.deviceHealthReason, spec.fieldDevice, FieldDeviceRecord.serialNumber. Existing CRs are untouched byte-for-byte.
  • AlarmDefinition gains one condition type, evaluated against the CM's written status.deviceHealth through the ControlModule watch the definition controller already holds — one poller, one source of truth, no second read path.
  • The HMI faceplate gains one conditional row; no name-derived semantics enter the client (lint-js-hmi-semantics stays clean).
  • No PA-DIM or NE 107 conformance is claimed — the surface reads one mandatory variable and says so.
  • Wizard captures for the whole #935 flow still wait on the #1336 discovery relocation, so screenshots and clips are shot once in the final navigation home.