# Description Implement the draft **OPC UA — Robot Intent** companion model end to end: task-level motion verbs, a Part 10 lifecycle, a server hosting surface, a client, two samples and a live integration suite. It replaces the opt-in convention API merged in #4127 with an implementation generated from a companion information model. ## Why OPC 40010-1 *Robotics* describes robot **topology** in detail and defines **no motion verbs at all** — its entire actuation surface is `Start`, `Stop` and loading a named program. A conformant client can discover everything about a robot's construction and cannot ask it to move anywhere. #4127 filled that gap with ten verbs in an application-owned namespace, resolved by BrowseName, and the file said what it was: *"opt-in, explicitly non-normative industrial operation conventions"*. That contribution established a **vocabulary**. It established no **lifecycle**, and the lifecycle is the harder half. A motion takes seconds; a pick takes a minute. OPC 10000-4 §5.12.2 discards a method result when the Session ends *"independent of the task actually performed at the Server"* — so a synchronous motion method does not merely time out, it **loses the outcome of work that has already physically happened**. OPC 10000-10 §4.1 gives the OPC Foundation's own resolution: a Method performs a calculation, a **Program** runs a batch process or a machine tool part program. So `SubmitIntent` returns as soon as the intent is *admitted*, and what it returns is a NodeId — an `IntentOperationState` (a Part 10 program instance) the client subscribes to for progress and reads for the result. ## What changed **Model and shared code** — `Opc.Ua.RobotIntent.NodeSet2.xml` is source-generated in `Opc.Ua.Robotics`, so the enums, the polymorphic intent structures, `IntentOperationState : ProgramStateMachineState`, the method states and the typed clients derive from the model rather than being hand-written. Its only `RequiredModel` is the base UA namespace, so a server can adopt it without OPC 40010 or DI. `PoseMath` implements the specification's Annex C conversion between its unit quaternion and the core `ThreeDFrame` — including the `asin` clamp that stops a pole orientation becoming a domain error, and the non-negative-`w` representative that makes two servers agree on four numbers. `FrameTree` composes transforms along the frame tree. **Verbs are a DataType hierarchy, not one Method each.** A single submission and a mission step are then the same shape, and a new intent is a subtype rather than a new method — which is what `AddOperation<TRequest,TResponse>` was working around. **Server** — `IntentControllerHost` owns admission in the specification's order, the queue with PLCopen buffer modes, blending, cancellation *with the server's right to refuse*, missions with a committed base and a revisable horizon plus an IEC 61131-3 step graph, safety-aware refusals, real-time channel brokerage, and the capability declaration. All thirteen Methods are wired; command authority and channel leases are released when the holding Session closes; every client-supplied NodeId is validated against the controller being commanded (clause 11.3). `RobotIntentNodeManager`, the fluent `IIntentControllerBuilder` and `AddRobotIntent`/`ConfigureRobotIntent` make it reachable, and `RobotIntentFacetCalculator` computes which of the specification's facets an instance actually satisfies. **Client** — `RobotIntentClient` discovers `Server/RobotIntent/Controllers` and reads what a robot accepts; `IntentOperationHandle` subscribes and completes on a terminal state, recovering the result after a reconnect because clause 6.7 promises it survives; `CommandAuthorityLease` and `RealTimeChannelLease` manage the two exclusive resources; fluent builders cover every intent and mission. **Samples** — all robotics samples move under `samples/Robotics/`. `IntentEnabledRobot` is one UR5e-style offset-wrist arm on a bench, declared completely enough to show what a conformant server publishes, with a simulated safety source so the clause 10.4 refusals are demonstrable. `IntentViewerClient` turns a **click on a target prim in the OpenUSD viewport** into an intent and watches the arm execute it; it also runs headless, which is how CI exercises it. **Intents execute serially**, which satisfies every `BlockingMode` constraint by construction: the specification forbids beginning a `Single` or `Hard` intent while another executes and merely *permits* `None` and `Soft` to overlap. The convention API (`RoboticsOperationConventions`, `RoboticsOperationsBuilders`, `RoboticsOperationsClient`) is removed. ### On safety — what this deliberately does *not* claim The interface is **non-safety-rated**, and that is a property of the technology rather than a scoping choice. OPC 10000-15 carries cyclic safety data from a SafetyProvider to a SafetyConsumer; the consumer's `RequestSPDU` holds an identifier, a monitoring number and one octet of explicitly **non-safety** flags, so a caller has no channel through which to supply safety-rated arguments. Every safety fieldbus (PROFIsafe, CIP Safety, FSoE, openSAFETY) expresses a safety command as a **continuously asserted cyclic signal**, because the integrity argument rests on the fail-safe state that follows when assertion *stops* — and a Method call has no defined behaviour when it stops being called. What the host does instead is **observe and refuse**: `UpdateSafetyState` is how the application reports what the safety system is enforcing, and admission then refuses on the same values a client can read, so a refusal is explainable from the address space rather than from Server-internal state. It may never *instruct* the safety system, and no Method here commands a safe motion function, changes an operational mode or clears a stop. ## Defects this found in the specification, fixed upstream Implementing a specification is the only way to find out whether it can be implemented. Seven defects went back to [marcschier/opcua-drafts#47](https://github.com/marcschier/opcua-drafts/pull/47) rather than being worked around here: - **Refusal was unobservable.** Clause 6.2 orders six distinct refusals and clause 5.8 calls the failure set "small and diagnosable on purpose" — but `SubmitIntent` returned only `IntentId` and `Operation`, so a client could see *that* something was refused and never *which*. It now returns `Accepted`, `Failure` and `Message`, and clause 6.2 states that a refusal returns `Good` rather than substituting a Bad `StatusCode`. - **The Part 10 promotion was illegal.** `IntentOperationType.ProgramDiagnostic` was declared as a Property of `PropertyType`; OPC 10000-10 declares it a Variable of `ProgramDiagnostic2Type` reached by `HasComponent`. The declaration added a *second* member beside the inherited one instead of promoting it — which is precisely why an earlier version of this branch had deleted it. Correcting the TypeDefinition made it generate cleanly with **no change to the curated Core**. - **Clause 9 said nothing about the Method surface.** A server could declare `MissionsSupported` true and omit `SubmitMission` entirely, because the mission and channel Methods are Optional. This implementation did exactly that until an integration test caught it. A fourth honesty rule and a table now fix which Methods each declaration implies. - **Conformance was not machine-readable, and two implementations disagreed.** Clause 12 defined conformance in terms of facets and gave a Server nowhere to publish which ones it satisfied, so every client had to re-derive Table 12.2 from the address space. This repository proved why that fails: the server''s `RobotIntentFacetCalculator` and the client''s `RobotIntentRules.DeriveFacets` each implemented clause 12.2 independently and **disagreed on essentially every row** — the client''s `Blending` was `BlendingSupported` alone where the server also requires all four blending buffer modes, its `QueuedIntents` was `MaxQueueDepth > 0` where the server also requires `Buffered`. A client and server from the same release answered "does this controller conform?" differently, and both were reading the specification correctly, because several rows are behavioural and no amount of browsing settles them. `IntentCapabilitiesType` now carries **`SupportedFacets`**, exactly as OPC 10000-5 does with `ServerCapabilitiesType.ServerProfileArray`, RI-Base requires it, and clause 12.2 separates structural requirements a Server shall meet before listing a facet from attested ones governed by clause 9. - `WaitIntentDataType.Signal` was unbounded where clause 11.3 has to check it; §11.3 gained a table fixing the expected type of every NodeId-valued member; §5.7.0 gave `Ready`/`ActiveIntent`/`ActiveMission` normative meaning; §6.9 bounded `RequestedLease`; §6.5 and §6.4 now say what a server that cannot differentiate `StopMode` must do, and which stop a superseded intent gets. ## Bugs the tests and reviews caught Each of these would have been silent in the field: - **The execution engine was never wired into the public API.** `IntentControllerBuilder.RegisterAsync` built the address space and never started an `IntentControllerHost`, so through the documented `AddRobotIntent` → `ConfigureRobotIntent` path **every Method returned `BadNotImplemented`** and the registered `IIntentExecutor` was never resolved. A browsable address space is not a working robot interface, and only a live integration test showed the difference. - **NodeId scoping failed open on an empty index.** A controller advertising `CallProgramIntentDataType` with no published `ProgramType` accepted an *arbitrary* NodeId as `CallProgram.Program` — and `CallProgram` runs code the server holds. - **Blending completed the predecessor at submission time** and abandoned its motion, telling the client it succeeded and publishing an all-zero pose at the origin as a genuine `AchievedPose`. - **Mission base immutability compared only identifiers**, so a client could keep the ids and swap the *motion* of an already-committed step. - **`FinalResultData/Result` was never registered** with the node manager: Browse showed it, Read returned `BadNodeIdUnknown` — so clause 6.7's promise to a Part 10 client did not hold. - `DisposeAsync` disposed primitives out from under in-flight executor code; re-using a terminated `IntentId` destroyed the retained result and duplicated a NodeId; neither side registered the Robot Intent encodeables, so `Pose3DDataType` did not decode; host startup was hooked to a task that ran in only one hosting shape. - The result was published **after** the state went terminal; capabilities were resolved against a stale namespace table; an empty `ContentFilter` arrives as an empty element array rather than a null filter, so testing only for null made every unconditional mission transition silently untaken — a mission would run its first step and stop. ### Found by a later test-quality audit, after the above was written An audit of the test suite itself found five tests whose **names** asserted a normative requirement while their **code** asserted something weaker or nothing at all. The worst **fabricated the answer it checked**: a helper caught a decode failure and invented "5 supported intents, 6 axes, all facets true", so six capability-honesty assertions across four tests could pass while reading nothing from the server. The fallback is gone and the suite passes with honest reads. Chasing the facet gaps it reported led to the clause 12 defect above. Two further defects followed from completing that work: - **`RI-Interop-40010` was never computed**, and the code documented the omission as impossible to fix "because the calculator receives only a Robot Intent controller, not the linked OPC 40010 model". That was wrong: Annex B's structural requirement is an **inverse** `HasIntentController` reference, reachable from `IntentControllerState` — and the integration suite was already browsing exactly that. A server implementing Annex B could not claim Annex B. - **The published claim could go stale.** `SupportedFacets` was a snapshot taken during `RegisterAsync`, so a reference attached afterwards left it wrong. It is now bound through `BindRead` and recomputed on read, so the claim tracks the address space by construction rather than by discipline — which matters for anyone bolting Robot Intent onto an existing OPC 40010 node manager, where the attach ordering is often not theirs to choose. Two tests that **could not fail** were also replaced: one asserted `AccessLevel` equals `CurrentRead` to pin a read-only marking, but `BaseVariableState` initialises every variable to exactly that, so deleting the marking left it green; it is now a write expecting `BadNotWritable`, including a real OPC UA `Write` service call over a live session. Finally, a build break that a plain build hides: `RoboticsAotTests` used `out string?` in a project declaring `<Nullable>disable</Nullable>`. Building the project **without** `-f` suppresses `CS8632` through `NoWarn=nullable` and succeeds; building it **with** `-f net10.0` resolves `NoWarn` differently, `CS8632` fires, and `TreatWarningsAsErrors` makes it an error. CI builds per-TFM, so this would have failed there while passing every local build to that point. ## Testing and coverage | | | |---|---| | `dotnet build UA.slnx -t:Rebuild` | **0 errors, 2 warnings** — both pre-existing `CA1861` in `tests/Opc.Ua.Tools.Tests`, a file this PR does not touch | | **Whole `UA.slnx` test suite, net10.0** | **50 of 52 assemblies fully green** (measured at `40d9cf213`, before the `master` merge; the other two are explained below) | | `Opc.Ua.Robotics.Tests` | **467** net10.0, **447** net48 | | `Opc.Ua.Robotics.Intent.Tests` (live client <-> server) | **20** on net10.0 and net48, run repeatedly | | `Opc.Ua.OpenUsd.Tests` | **904** after merging `master` (664 before; the rest arrive with #4160 / #4162 / #4180) | | `Opc.Ua.Aot.Tests` | **121/121** | | `Opc.Ua.Di.Tests` (regression from the sample move) | **364/365**, one pre-existing skip | | **Changed-line coverage** | **90.7 %**, no file below 80 % — comfortably above the enforced band, which after #4158 applies only to patches larger than 100 changed lines | The two non-green assemblies are **not** caused by this branch. Rather than assert that, I ran the same selections at the merge-base `ae241b662` on the same machine: - `ServiceCallReassemblerTests.PrivateRequestSummaryFormatsKnownRequestKinds` expects `12.5` and gets `12,5` — a culture-sensitive assertion failing on a European locale. Fails 4/4 on **both** commits. - `CertRotationLiveTests.L2Cert2RotateCertificateDuringServerRestartRecoversSharedChannelAsync` fails on a teardown count. Fails 4/4 on **both** commits. - A third failure seen once, AOT `FindServersAsync` with `BadSecureChannelClosed`, did not reproduce: 4/4 on retest and 121/121 in the final run. Environmental. Two things are worth calling out because they would otherwise be invisible: - `tools/**` is **not** in the `coverage-thresholds.json` ignore list, and no test project referenced the OpenUSD tools assemblies — so their changed lines counted as uncovered and the real patch figure was 75 %, exactly the blocking threshold. Rather than widening the ignore list, the pure decisions in the viewport host (pointer-to-physical-pixel conversion, `RenderPickRequest` construction, stale-retry, backend discovery, pick-mode selection) were separated from the Avalonia/native shell and tested headless against a fake picking backend. - `dotnet build UA.slnx` **hides warnings on unchanged projects**; only `-t:Rebuild` surfaces them. That is how a `CA2263` introduced by a net48 portability fix survived several "zero warning" builds. The multi-target requirement earned its keep repeatedly: it caught a default interface implementation, `ArgumentNullException.ThrowIfNull`, `string.Create` with an interpolated handler, `ValueTask.FromResult`/`CompletedTask`, `Task.WaitAsync`, generic `Enum.GetValues` and generic `Enum.IsDefined`, and a non-generic `TaskCompletionSource` — none of which the net10.0 build complained about. A net10.0-**only** hang in the simulated executor and two load-sensitive flaky tests were also found and fixed at the cause. ### Merged with `master` `master` (through `e73e71184`) is merged in as `08205d81d`. Four conflicts, all where the two branches touched the same robot and OpenUSD surfaces, and none resolved by dropping a side: - #4162 added six files to `samples/MinimalRobotServer/` while this branch moved that directory to `samples/Robotics/MinimalRobotServer/`. They are kept at the moved path, and `UA.slnx` takes the union of both sides' new projects. - #4160 replaced `object` with a `UsdValue` union and rewrote `Flatten`; this branch had reformatted the same method and fixed a path in its doc comment. Git produced a hybrid that did not compile. The resolution keeps `master`'s `UsdValue` arm and `Flatten(UsdValue)` overload in this branch's block-bodied form, with the path fix retained. - `master` inserted `FindStageCamera` directly before two methods this branch had relocated, so the merge duplicated `WriteStageUsda` and `GetPrivateStateRoot`. Only `FindStageCamera` is genuinely new — `master`'s diff shows the other two as unmodified context — so the duplicates are dropped and this branch's `internal` versions kept, including the `GetPrivateStateRoot(string?)` overload the connector tests use to stay out of the real user profile. - `master` moved the OpenUSD stack to `0.4.0-alpha`; this branch pins `OpenUsd.Rendering` in the test project so the renderer-pick tests can reach the picking backend. Left at `0.1.0-alpha` that pin is below what the viewer now resolves transitively, which is `CS1705`. The pin follows `master`. CI was green on `baaf35e9` with **190 checks passing and none failing**, across every build leg (net472 / net48 / netstandard2.0 / netstandard2.1 / net8.0 / net9.0 / net10.0, Debug and Release) and the full test matrix on Windows and Linux. Since then `master` has been merged twice more and two CI-stability fixes landed (see below), so the current head is re-running. Locally, `dotnet build UA.slnx -t:Rebuild` gives **0 errors** and the 2 pre-existing `CA1861` warnings; Robotics **467** net10.0 / **447** net48, Robot Intent **20** on both, OpenUSD **904**, AOT **121/121**, source generation **3771**. ### Review round Five reviewers over disjoint areas found defects worth calling out, all fixed in `3ffec2c1`: - **A refused mission step was reported `Succeeded`.** The advance path returned the same `false` for "steps exhausted" and "step refused", so a protective stop between steps told a supervisory system the mission had completed while the robot stood halted with work unexecuted. - **Three attacker-controlled NodeIds reached the executor unvalidated** — `Pick.Tool`, `Place.Tool`, `ToolChange.DockStation`. `DockStation` drives a physical tool-exchange motion to a location the server never authorised. The replacement test enumerates NodeId members by reflection so the next one added cannot be missed. - **A lock-order inversion that loses a stop.** `CanCancel` and the mission condition evaluator ran under the host lock while the executor's own thread re-entered it via `ReportBlendBegin`. - **A non-terminal executor outcome killed the pump** from outside the `try/catch` that exists because the executor is untrusted — `Ready` stayed true and submissions kept being accepted while nothing moved. - **`Pause` published `Suspended` while the robot kept moving.** Now queue-only and honest about it; the specification gained the matching rule ([opcua-drafts `357a558`](https://github.com/marcschier/opcua-drafts/pull/47)). - **`RI-RealTimeChannel` and `RI-Safety` were false claims** — granted on an empty folder with no methods, and on the mere presence of a Mandatory node. - **The Part 10 promotions never promoted.** Namespace-1 BrowseNames declared second members beside the inherited ones; the conformance test could not see it because its lookup stripped the prefix. Fixed upstream and regenerated. - **Renderer picking never worked.** The reflective probe bound a renderer that is not a picking backend and throws off its owner thread. It now binds only `IRenderPickingBackend`, falls back immediately and loudly, and the docs say plainly that renderer picking is unavailable with this package version. - **The client lost command authority the instant it gained it** — monitored items report the current value first, and the lease treated every notification as a loss. ### CI stability fixes for master-side failures The CI split in #4158 turned on legs that had never run, and two of the failures they exposed were real bugs on `master` rather than anything this branch introduced. Both are fixed here because they were blocking every branch: - **ECC key generation was broken on macOS.** `ExportExplicitParameters` throws `PlatformNotSupportedException` there — Apple's SecurityTransforms supports named curves only — and it was called for exactly one value, the curve order `n`. The four curves OPC 10000-12 admits have published orders, so those are carried directly, with `CurveOrderTableMatchesThePlatform` checking each against the platform's own exported order wherever export works, so the constants cannot silently drift. This accounts for the two ECC failures in `test-macOS-latest-Server`. - **The certificate-leak detector raced asynchronous disposal.** It polled a fixed budget and reported whatever it saw, which cannot distinguish a genuine leak (count steady) from a suite still draining fire-and-forget disposals (count falling) — on a loaded agent that produced `Certificate leak detected: 2 instance(s)` with every test passing. It now waits for the count to **settle**, so a real leak is reported sooner than before while a slow drain gets as long as it keeps progressing. Shared by every suite that asserts no certificate leaks. The remaining macOS failures on `master` (`Gds`, `Tools`, `Redundancy.Samples`, `aot-macos`) have different root causes, are not reproducible without a macOS host, and are **not** addressed here. ### Merged with `master` again `master` through `416d619c4` (#4158, the CI matrix split and graduated coverage bands) is merged in as `08ae4528`. One conflict, in `docs/DeveloperGuide.md`, where both sides had deliberately rewritten the coverage-gate section: - `master`'s rewrite is kept in full — graduated `patch.bands`, Codecov being informational-only, and where the numbers surface per CI system. - This branch's paragraph about the `ignore` globs is kept too, re-sited ahead of those subsections. I re-checked it against the merged `coverage-thresholds.json` rather than assuming it still held: the list is still `tests/**`, `samples/**`, `obj`, `bin`, `*.g.cs`, with **`tools/**` absent** — so the warning that anything under `tools/` is measured like product code, and that an assembly no test project references contributes changed lines counted as *uncovered*, is still accurate and worth keeping. - `master`'s "reproduce a coverage failure locally" line supersedes this branch's near-identical one, so only `master`'s survives. Post-merge: `dotnet build UA.slnx -t:Rebuild` **0 errors**, 2 pre-existing `CA1861`; Robotics **467** net10.0 / **447** net48; Robot Intent **20** on both. ### Getting CI green The pipeline had never actually run on this branch until now (it was waiting on the fork-PR approval gate), so the first run surfaced four real problems, each fixed at source: - **Seven build legs failed** because `IntentEnabledRobot` pins `net10.0` for the OpenUSD payload but never opted into `RestrictForLegacyTfm`. On the legacy legs the libraries build as net472/net48, where `Opc.Ua.Types` exposes the `System.Threading.Lock` polyfill publicly, and that collides with the sample's own net10.0 BCL (`CS0433`). `Opc.Ua.OpenUsd.Connector.Viewer` was already using the opt-out for the same reason. - **A Linux-only test failure** turned out to be a real defect, not a platform quirk: `SetPose` published the *commanded* interpolated pose beside joint frames derived from the *solved* joints, so the two disagreed by the inverse-kinematics residual — about 8 mm, constant after any real motion. That is the lying-digital-twin defect one level deeper than the per-step IK fix reached. `SetPose` now derives both from the joint angles. - **Three source-generator tests** asserted the old emitted shape. The clause 6.2 Null-Variant fix was implemented in `ObjectTypeProxyGenerator` so every generated proxy benefits, which changed that shape; the tests are updated and strengthened to pin the `IsNull` branch as well. - **A CI-only timeout** in the simulated arm drain: the executor runs on a manual clock so the tests do not depend on machine speed, but the drain guarded itself with a wall-clock deadline, which put that dependence back. CI runs with coverage instrumentation, which is enough to take a per-step IK probe from ~0.6 s to over 5 s. The guard is now a simulated step budget. One failure was **not** from this PR and did not recur: a Roslyn `AccessViolationException` compiling `Opc.Ua.ISA95` on one leg, while the Debug leg of the same target passed on identical code and the Release build succeeds locally. ## Related Issues - Relates to #3827 — the request that prompted the robotics intent work. - Builds on #4127. The companion specification is drafted in the open at [marcschier/opcua-drafts#47](https://github.com/marcschier/opcua-drafts/pull/47) (`metaverse-specs/robot-intent/`), with the prior art and the reasoning behind each decision in the research document beside it. **Nothing in it is normative or endorsed by the OPC Foundation, and its NodeIds and namespace URI are provisional** — which is the main reason this is a draft. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. On the test checkbox, precisely: the whole `UA.slnx` suite now runs on net10.0 with **50 of 52 assemblies fully green**, and `Opc.Ua.Robotics.Tests` passes on **all five** target frameworks including net472, net8.0 and net9.0 which had never previously been exercised. The two remaining failures are reproduced identically at the merge-base and are detailed above, so I have ticked the box; if you would rather it stayed unticked until those two are green, say so and I will revert it. ## Points I would most like feedback on 1. **The namespace and NodeIds are provisional.** They come from a draft specification, not from the OPC Foundation. If this lands before the model is registered, the identifiers will move. 2. **The model lives in `Opc.Ua.Robotics`.** It is standalone on the base UA namespace and takes no dependency on OPC 40010, so a separate `Opc.Ua.RobotIntent` package would arguably be cleaner. Keeping it here was a deliberate call — this is where the robot-facing API already is — but it is the easiest thing to change now and the hardest later. 3. **Removing the convention API from #4127 is a breaking change** for anyone who adopted it. It was explicitly marked non-normative, so I have assumed that is acceptable — please say if a deprecation period is wanted instead. 4. **Serial execution** is a deliberate simplification. Concurrent `None`/`Soft` intents would be an optimisation, not a correction, but it is worth agreeing that reading. 5. **`samples/Robotics/` reorganisation** moves `MinimalRobotServer`. Happy to split that into its own commit if it makes review easier. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1dab1302-19c5-4a9b-a50c-d97d389713aa Copilot-Session: a248a589-9a20-4372-868e-5d347e57001b
PumpDeviceIntegrationServer
A self-contained, NativeAOT-friendly OPC UA server that demonstrates
the OPC 40223 Pumps companion specification
with a full live simulation, wired through the fluent
INodeManagerBuilder API and the additive OPC 10000-100 topology-element
builder integration.
The pump sample is the integration test for every fluent API extension
shipped under src/Opc.Ua.Server/Fluent/. Each extension is
documented in
Source-generated NodeManagers — Building richer node managers.
The simulated asset is a SimPump Corp PumpX-2000. Its nameplate,
engineering ranges, alarm trip points and simulation profile are all
published in DATASHEET.md — an official-style product
datasheet that the server is aligned to and that
tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs asserts against,
so document and address space cannot drift apart.
The simulated device
| Manufacturer / model | SimPump Corp PumpX-2000 |
| Type | Single-stage end-suction centrifugal process pump |
| Rated duty point | 25 m³/h (6.93 kg/s) at 25.5 m head (2.5 bar Δp) |
| Rated efficiency / shaft power | 72 % / 2.41 kW |
| Motor | 3.0 kW, 400 V 3~ 50 Hz, 2900 min⁻¹ |
| Units in the address space | SN-001 (Pump_1), SN-002 (Pump_2), … (--pumps N) |
| Bearing-temperature trip points | 363.15 K high, 373.15 K high-high |
| Full specification | DATASHEET.md |
Volumetric flow is the only independent variable in the simulation.
Head, differential pressure, mass flow, efficiency and shaft power all
follow from the datasheet characteristic curves
(H(Q) = 32 − 0.0104·Q², η(Q) = 72·(1 − 0.6·((Q−25)/25)²),
P = ρ·g·Q·H/η), so the published values are mutually consistent at
every tick rather than being independent sine waves.
Running the sample
cd samples/PumpDeviceIntegrationServer
dotnet run -c Release
The server listens on opc.tcp://localhost:62542/PumpDeviceIntegrationServer
by default. Override with --host localhost, --port 62550, and
--pumps N (or the matching host, port, and pumps environment
variables). --pumps defaults to 2 and accepts values from 1 to 100.
Sample console output:
info: Opc.Ua.Server.MasterNodeManager
MasterNodeManager.Startup - NodeManagers=3
info: Opc.Ua.Di.Server.DiNodeManager
Materialised OpenUSD facility (root ns=4;s=OpenUSD, PlantStage ns=1;s=OpenUSD_Stages_PlantStage).
info: Opc.Ua.Di.Server.DiNodeManager
Materialised 'Pump_1' (PumpType) under DeviceSet, NodeId=ns=1;s=5001_Pump_1.
info: Opc.Ua.Di.Server.DiNodeManager
Materialised 'Pump_2' (PumpType) under DeviceSet, NodeId=ns=1;s=5001_Pump_2.
info: Opc.Ua.Di.Server.DiNodeManager
Materialised ProductionLine (aggregates 1..n pumps).
info: Pumps.PumpNodeManager
Configuring PumpNodeManager fluent wiring...
info: Opc.Ua.Di.Server.DiNodeManager
PumpNodeManager: address space ready (10997 predefined nodes).
info: Opc.Ua.Server.Hosting.OpcUaServerHostedService
OPC UA server listening at opc.tcp://localhost:62542/PumpDeviceIntegrationServer.
Browse to Objects > DeviceSet > Pump_1 in any OPC UA client (e.g.
UaExpert) to explore the first simulated pump. BrowseNames use
identifier-safe names (Pump_1, Pump_2, ...); DisplayNames keep the
operator-friendly labels (Pump #1, Pump #2, ...). Pass --pumps N
or set pumps=N to materialise more instances. Every pump is wired
through the same simulation, alarm, history, identification, maintenance,
and declarative DI ConfigureDevicesFor flow, so pumps added after
startup automatically join the same live simulation. All pumps are units
of the same PumpX-2000 product and differ only in serial number, asset
id, component name and installation bay. They publish monitored data
changes every 250 ms, with deterministic phase offsets so their values do
not move in lockstep.
Subscribe to the EventNotifier attribute on any pump to receive alarm
condition events when its simulated MotorOverheat state activates or
clears. Each pump is also registered as a root notifier, so the same events
are available from a subscription on the Server object.
Address space
flowchart TD
Objects["Objects"]
DeviceSet["DeviceSet<br/><i>OPC 10000-100 (DI)</i>"]
P1["Pump_1 · SN-001<br/><i>PumpType, DisplayName 'Pump #1'</i>"]
P2["Pump_2 · SN-002<br/><i>PumpType, DisplayName 'Pump #2'</i>"]
Id["Identification<br/><i>21 nameplate properties</i>"]
Op["Operational<br/><i>OPC 40001-1 (Machinery)</i>"]
Meas["Measurements"]
Ev["Events<br/><i>EventNotifier</i>"]
Maint["Maintenance<br/>└ GeneralMaintenance"]
Diag["Diagnostics<br/><i>ad-hoc FunctionalGroup</i>"]
Usd["OpenUsdRepresentation<br/><i>twin bindings + ShaftAngle</i>"]
Vals["DifferentialPressure · FluidTemperature<br/>BearingTemperature · PumpPowerInput<br/>MassFlow · PumpEfficiency · Level<br/>NumberOfStarts"]
Alarm["OverTempAlarm<br/><i>NonExclusiveLimitAlarmType</i>"]
SupF["SupervisionProcessFluid<br/>└ Cavitation"]
SupP["SupervisionPumpOperation<br/>└ MotorOverheat"]
Objects -->|Organizes| DeviceSet
DeviceSet -->|Organizes| P1
DeviceSet -->|Organizes| P2
P1 -->|HasComponent| Id
P1 -->|HasComponent| Op
P1 -->|HasComponent| Ev
P1 -->|HasComponent| Maint
P1 -->|HasComponent| Diag
P1 -->|HasComponent| Usd
Op -->|HasComponent| Meas
Meas --> Vals
Ev -->|HasComponent| Alarm
Ev -->|HasComponent| SupF
Ev -->|HasComponent| SupP
P1 -.->|HasNotifier| Ev
Alarm -.->|SourceNode| Vals
Vals -.->|bound signals| Usd
Only the subtree of Pump_1 is drawn; every further pump (Pump_2, …,
up to --pumps N) carries an identical one. Diagnostics is the one
group that is not part of the model — it is added declaratively per pump
by the non-typed WithFunctionalGroup(QualifiedName, ...) overload from
Program.cs. OpenUsdRepresentation carries the twin bindings described
in The OpenUSD twin.
Startup and hosting flow
sequenceDiagram
participant Host as HostApplicationBuilder
participant DI as AddOpcUa().AddServer()
participant Factory as PumpNodeManagerFactory
participant NM as PumpNodeManager
participant Runner as IDiPostSetupRunner
Host->>DI: Configure<PumpDeviceIntegrationOptions>(--pumps N)
Host->>DI: AddNodeManager<PumpNodeManagerFactory>()
Host->>DI: ConfigureDevicesFor<PumpNodeManager>(...)
DI->>Factory: CreateAsync(server, configuration)
Factory->>NM: new PumpNodeManager(.., postSetupRunner, options)
NM->>NM: LoadPredefinedNodesAsync<br/>AddOpcUaDi + Machinery + Pumps
NM->>NM: OnAddressSpaceReadyAsync
loop for each of N pumps
NM->>NM: ConfigureInstancesAsync → Pump_n (PumpType)
NM->>NM: MaterialiseNameplate + optional children
end
NM->>NM: CreateFluentBuilder().Configure(Configure).Seal()
Note over NM: Configure wires identification, maintenance,<br/>measurements, alarms and history per pump
Note over NM: Seal starts the 250 ms simulation loop
NM->>Runner: post-setup pipeline
Runner->>NM: TopologyElement(pumpNodeId).WithFunctionalGroup("Diagnostics")
Simulation and alarm dataflow
flowchart LR
Tick(["250 ms tick<br/>builder.Simulation(...)"])
Adv["AdvanceSimulation()"]
Sim["PumpSimulationState.Publish<br/><i>per pump, phase offset 17 ticks</i>"]
Curves["Datasheet curves<br/>H(Q) · η(Q) · P = ρgQH/η"]
Upd["IValueUpdater<T>.SetValue"]
Vars["Measurement variables<br/><i>EURange + EngineeringUnits</i>"]
MI["MonitoredItems<br/><i>client subscriptions</i>"]
Ovr["MotorOverheat<br/><i>≥ 363.15 K, hysteresis 361.15 K</i>"]
Cav["Cavitation<br/><i>level < 2.10 m, hysteresis 2.20 m</i>"]
Alarm["OverTempAlarm<br/><i>ActivatesAlarm edge tracker</i>"]
Notif["HasNotifier chain<br/>Pump → Server object"]
Ev(["Condition events"])
Shaft["ShaftAngle<br/><i>∫ speed dt, speed ∝ ṁ / ṁ rated</i>"]
Usd(["OpenUSD twin bindings"])
Tick --> Adv --> Sim --> Curves --> Upd --> Vars --> MI
Sim --> Ovr --> Alarm --> Notif --> Ev
Sim --> Cav --> Vars
Vars -.->|SourceNode| Alarm
Curves --> Shaft --> Usd
Vars -.->|bound signals| Usd
Validating the address space
The
Pump Address Space Validation
workflow runs nightly and can also be started manually. It builds this sample,
installs the latest stable
OpcUaAddressSpaceChecker
global tool, and validates all PumpType instances.
To reproduce the validation locally, start the server and run the following commands in another terminal:
dotnet tool install --global OpcUaAddressSpaceChecker
opcua-check-address-space `
--endpoint opc.tcp://localhost:62542/PumpDeviceIntegrationServer `
--type "nsu=http://opcfoundation.org/UA/Pumps/;i=1052" `
--severity-threshold warning `
--view-completeness complete `
--require-complete-view
The nightly check keeps the tool's default auto validation-view policy and
fails on confirmed errors or any checker execution failure.
The OpenUSD twin
The server also publishes an OpenUSD representation of the pump line
(the draft OPC UA — OpenUSD Bindings companion model, see
docs/OpenUsd.md) and serves its USD layers as
embedded assets, so a connector can render the twin with no external
asset resolver.
Every configured pump is a twin in its own right. The DeviceSet
carries a plant-level representation anchored on /Plant whose single
Many component binding is scoped to PumpType, so a connector composes
one @pump.usda@</Pump> reference prim per pump — /Plant/Pumps/Pump_1,
/Plant/Pumps/Pump_2, … — and each pump's own bindings drive only its own
prim. Start the server with --pumps 6 and six fully modelled machines
appear, each turning at its own speed, each with its own gauges, fluid
level and alarm halos. Each pump publishes its bay as a
ThreeDCartesianCoordinates value bound to xformOp:translate, which is
what lays them out in a row without the stage having to author anything
per pump.
The composition arc is Reference, not Instance: an instanceable prim
turns its descendants into a shared prototype, and a shared prototype
cannot carry the per-pump impeller rotation, casing colour or needle
positions that make each machine read as its own.
Assets/Plant.usda models the pump as a real machine: a horizontal
long-coupled end-suction centrifugal pump built to EN 733 (formerly
DIN 24255), size 65-200, following the published dimensions of the
Grundfos NK 65-200 / KSB Etanorm 65-200 family and driven by an
IEC 160M motor on a fabricated baseplate.
| Item | Value |
|---|---|
| Baseplate | 1.80 × 0.46 × 0.12 m |
| Shaft centreline above baseplate | 0.160 m (the IEC 160M frame number is the shaft height) |
| Volute casing | 0.355 m outer diameter × 0.110 m wide |
| Suction flange | DN80 axial, OD 0.200 m (EN 1092-2 PN16) |
| Discharge flange | DN65 vertical, OD 0.185 m (EN 1092-2 PN16) |
| Impeller | 0.198 m, six backward-curved vanes |
| Motor frame | 0.254 m outer diameter × 0.615 m long |
| Bay pitch | 2.4 m |
Livery is KSB signal blue (RAL 5005) for the wetted castings and
RAL 7035 light grey for the motor. pump.usda is that machine as a
self-contained component asset, referenced once per configured pump. It is
generated from the /Plant/Pumps/P101 master by
Assets/generate_pump_assets.py — edit the master and re-run the
generator, never the generated layer. The master itself is authored
active = false, because the composed pumps are what render.
Two departures from a real pump are deliberate, so the twin can be seen: the suction pipe is drawn as a stub leaving the casing eye open (a cutaway, as trade-show display pumps are presented) and the coupling guard is a cage rather than a solid barrel. Both let you watch the shaft turn.
The hall
The rendered hall holds exactly the pumps the connected server simulates —
one referenced pump.usda prim per configured pump, one bay every 2.4 m along
+Y, each with its suction vessel behind it. Nothing else is drawn: the
ProductionLine aggregation and the cross-server component are address-space
topology, not machines anyone is driving, so composing them put pumps in the
twin that no client could account for, and a pump federated from another
server is not this server's to show at all.
HeroCamera is an operator's viewpoint: eye height 1.65 m in the aisle on the
discharge side, pitched 7° below horizontal, framing every configured pump at a
three-quarter angle with the vessels behind them. The framing holds from one
pump up to eight, so it does not have to be retuned for --pumps N. Pass
--camera /Plant/HeroCamera to start on it.
An error draws a red circle on the floor around the machine. A ring reads
from anywhere in the hall and from any camera angle; a lamp on a mast only reads
when you happen to be looking straight at it. The ring says that a pump is in
alarm; the two fault halos — at the suction eye for cavitation, on the bearing
bracket for motor overheat — say why. All three are authored invisible and
only a live supervision binding reveals them, so a machine nothing is bound to
never shows an alarm it did not raise.
Live bindings
All paths are relative to the pump's own prim, so every pump drives its own copy of every target.
| Source | USD target | Effect |
|---|---|---|
BayPosition |
xformOp:translate |
places the pump in its bay |
ShaftAngle |
…/Impeller.xformOp:rotateZ |
turns the shaft, impeller and coupling |
ShaftAngle |
…/Motor/FanBlades.xformOp:rotateZ |
motor cooling fan |
BearingTemperature |
…/Body.primvars:displayColor |
casing colour, blue (cool) → red (hot) |
BearingTemperature |
…/PowerEnd/TempGauge/Needle.xformOp:rotateZ |
bearing-temperature gauge |
DifferentialPressure |
…/Discharge/Gauge/Needle.xformOp:rotateZ |
discharge pressure gauge |
FluidSurfacePosition |
…/SuctionVessel/Surface.xformOp:translate |
liquid surface rides on the published Level |
FluidTemperature |
…/Suction/Neck/Mat/Surface.inputs:diffuseColor |
suction line tint |
MassFlow, PumpEfficiency, NumberOfStarts |
…/Motor/Nameplate.inputs:* |
readouts a viewer shows on selection |
| any supervision alarm | …/AlarmRing.visibility |
red circle on the floor around the machine |
Cavitation |
…/Suction/CavitationHalo.visibility |
cavitation, at the suction eye where the fault is |
MotorOverheat |
…/OverheatHalo.visibility |
overheat, on the bearing bracket |
SpeedSetpoint |
…/Impeller.inputs:speedSetpoint |
opt-in UsdToUaCommand, fail-closed |
MassFlow is a rate, so binding it straight to a rotation op pins the
shaft at a fraction of a degree and the pump looks dead. The simulation
integrates the running speed into a ShaftAngle instead, and the binding
scales it down to a legible ~45°/s — a real 2900 rpm shaft would alias
into a stroboscopic blur at any practical sampling rate. Speed follows
flow, so the impeller visibly slows and picks up with the duty point, and
the phase offset the simulation gives each pump means no two shafts are
ever at the same angle.
The OverTempAlarm condition itself is deliberately not bound. The
fluent alarm builder leaves the condition's state children on their
standard namespace-0 declaration NodeIds — which
PumpInstanceNodeIdRegressionTests pins — so every pump's alarm shares
one ActiveState, Severity and AckedState node, and binding those
would ring every pump in the hall at once. The per-pump supervision
states are the alarm indication instead: they are genuinely per instance,
and they are what drives the condition through ActivatesAlarm in the
first place.
A real pump shaft is horizontal, but the binding contract fixes the
driven operation as xformOp:rotateZ. Impeller therefore carries a
static xformOp:rotateY = 90 ahead of xformOp:rotateZ in
xformOpOrder, which lays its local Z along the world shaft axis. The
impeller and the coupling both hang off that one rotating prim, so they
turn together — as they do on the real machine. The gauge needles use the
same trick with xformOp:rotateX = 90, because their dials face the
plant Y axis.
Which op a bound prim has to declare depends on the render target, and
getting it wrong fails silently. A connector accumulates Translation,
Rotation and Scale into a single xformOp:transform matrix, so
that it never has to rewrite xformOpOrder — the list is uniform, and
an opinion in the asset layer cannot be cleared from the stronger layer a
connector edits. Every other xformOp: property, such as the scalar
xformOp:rotateZ above, is authored under its own name. USD evaluates
only the ops named in xformOpOrder, so a prim bound to a Translation
target must declare xformOp:transform — the pump root and the suction
vessel's fluid surface both do. Naming xformOp:translate there instead
makes USD discard every value written to it: the pumps keep reporting
their bay positions over OPC UA, and every one of them renders on the
origin, so the hall looks like it holds a single machine.
TransformBindingsTargetDeclaredXformOpsAsync checks each transform
binding against the op the served asset actually declares.
Because the render targets expect degrees Celsius and bar while OPC
40223 publishes Kelvin and Pascal, the colour bindings declare the
conversion themselves (offset: -273.15 and scale: 1e-5); §5.8
applies Scale then Offset. The gauge scales are derived from the
PumpDatasheet engineering ranges, so a datasheet change moves the
needles with it. PumpEfficiency is a readout rather than a colour: the
DisplayColor ramp models a temperature, and colouring efficiency with
it would read as a lie.
Viewing it
Run the server, then point the connector at it with --view:
Opc.Ua.OpenUsd.Connector --server opc.tcp://localhost:62542/PumpDeviceIntegrationServer `
--insecure --view --fetch-assets .\stage-cache
The connector fetches the server-delivered layers, composes a
self-contained stage and streams the live OPC UA values into
live.usda and the viewport. See
tools/Opc.Ua.OpenUsd.Connector.
Running in Docker
A Dockerfile is provided that builds the Release
publish output on the .NET AzureLinux 3 base images and runs it as a
non-root user.
Build from the repository root, not from this folder. The image needs the full source tree (
src/,samples/,tools/), so the Docker build context must be the repo root and the Dockerfile is selected with-f. Runningdocker build .from inside this folder fails fast with a message telling you the correct command.
# from the repository root:
docker build -f samples/PumpDeviceIntegrationServer/Dockerfile `
-t pumpdeviceintegrationserver:local .
Run it, publishing the OPC UA port:
docker run --rm -p 62542:62542 pumpdeviceintegrationserver:local
Inside the container the endpoint binds to 0.0.0.0 so it is reachable
from the host. Override the bind host and port via environment variables:
docker run --rm -p 62550:62550 `
-e host=0.0.0.0 -e port=62550 -e pumps=4 `
pumpdeviceintegrationserver:local
The server creates its certificate/PKI store under /app at runtime.
To persist certificates across container restarts, mount a volume:
docker run --rm -p 62542:62542 `
-v pump-pki:/app/pki `
pumpdeviceintegrationserver:local
The image is built and published to the GitHub Container Registry by the
pump-device-integration-server-docker.yml
workflow on every push to master and on manual dispatch.
What the sample demonstrates
| Feature | Where |
|---|---|
AddOpcUa().AddServer(...).AddNodeManager<T>() hosting |
Program.cs |
| Multi-model composition (DI library + locally source-generated Machinery + Pumps) | PumpNodeManager.cs LoadPredefinedNodesAsync |
Optional nameplate materialisation via generator-emitted AddXxx(context) helpers across three namespaces (DI / Machinery / Pumps) |
PumpNodeManager.cs MaterialiseNameplate |
Identification properties via WithProperty(name, value) |
PumpNodeManager.Configure.cs WithIdentification |
Optional-child materialisation via generator-emitted AddXxx(context) helpers (Operational / Measurements / Events / SupervisionProcessFluid / SupervisionPumpOperation / Maintenance) |
PumpNodeManager.cs MaterialisePumpOptionalChildren |
Engineering units / EURange via WithEngineeringUnits / WithEURange |
CreatePumpSimulation |
Push-style monitored value updates via Bind(out IValueUpdater<T>) |
CreatePumpSimulation |
Discrete NumberOfStarts counter published on change |
CreatePumpSimulation |
| One 250 ms simulation tick for all phase-shifted pumps | Configure → AdvanceSimulation |
| Datasheet-driven simulation (one independent variable, derived values) | PumpDatasheet.cs + PumpSimulationState.Publish |
Browsable/subscribable limit alarm with thresholds and acknowledge handler via CreateLimitAlarm(...).WithLimits(...).MonitorVariable(...) |
CreatePumpSimulation |
Boolean supervision (TwoStateDiscreteState) → alarm activation, condition raise/clear, and reported events via .ActivatesAlarm(...) |
CreatePumpSimulation |
EventNotifier, HasNotifier, and HasEventSource instance wiring |
PumpNodeManager.cs + fluent alarm builders |
| Cross-namespace path resolution (Pump_1 in Pumps NS → Operational in Machinery NS → Measurements in Pumps NS, all in one unqualified browse path) | src/Opc.Ua.Server/Fluent/BrowsePathResolver.cs |
Declarative ConfigureDevicesFor topology-element configuration adding an application-namespace Diagnostics functional group to every generated PumpType instance |
Program.cs |
| In-memory historian wiring so NodeSet-declared historical access is genuinely serviceable for all analog measurements and historized supervision booleans | PumpNodeManager.Configure.cs UseHistorian() / Historize() |
One OpenUSD twin per configured pump: per-pump prim, signals and bindings, composed from one component asset by a Many component binding |
OpenUsdRepresentation.cs + OpenUsdComposition.cs |
Architecture
PumpDeviceIntegrationServer/
├── Program.cs # AddOpcUa().AddServer(...).AddNodeManager<T>()
│ # + ConfigureDevicesFor diagnostics for every pump
├── PumpNodeManager.cs # Hand-written FluentNodeManagerBase
│ # + LoadPredefinedNodesAsync (multi-model)
│ # + CreateAddressSpaceAsync (builder setup)
├── PumpNodeManager.Configure.cs # partial — fluent wiring + simulation tick
├── OpenUsdRepresentation.cs # partial — one twin per pump: prim path,
│ # signal Variables and live bindings
├── OpenUsdComposition.cs # partial — plant aggregation (one prim per
│ # configured pump) + ProductionLine demo
├── PumpDatasheet.cs # DATASHEET.md as compile-time constants
├── DATASHEET.md # official-style PumpX-2000 product datasheet
├── PumpDeviceIntegrationServer.csproj # ProjectReference to Opc.Ua.Di model lib
│ # AdditionalFiles for Machinery + Pumps
│ # NodeSet2 (consumed by source generator)
├── Assets/
│ ├── Plant.usda # stage master (P101 is the authoring
│ │ # master, deactivated so only the
│ │ # composed pumps render)
│ ├── pump.usda # generated component asset, one per pump
│ └── generate_pump_assets.py # regenerates it from the P101 master
├── Model/
│ ├── Opc.Ua.Machinery.NodeSet2.xml # AdditionalFiles — build-time only
│ └── Opc.Ua.Pumps.NodeSet2.xml # AdditionalFiles — build-time only
└── Properties/AssemblyInfo.cs
The Opc.Ua.Di model library is consumed as a project reference (its
types live under the Opc.Ua.Di namespace and are source-generated
from the ModelDesign XML). Cross-namespace references from Machinery
and Pumps to DI types resolve through the
[assembly: ModelDependencyAttribute] carried in the Opc.Ua.Di
assembly — no DI NodeSet2 XML needed in this project. The unified
attribute carries the compact type-table payload that the consumer's
source generator imports at compile time; see
ModelDependencies.md for the wire
format and consumer-side flow.
The Machinery and Pumps NodeSet2 XMLs are source-generated locally
inside this assembly via the <AdditionalFiles> plumbing in the
.csproj. The generator emits typed *State classes, NodeId tables,
and the AddOpcUaMachinery / AddOpcUaPumps extension methods that
LoadPredefinedNodesAsync calls. No runtime XML loading happens — the
Model/ folder is a build-time input only. Consumer assemblies that
want to reference Machinery or Pumps the same way they reference
Opc.Ua.Di should source-generate against the model XML inside their
own assembly using the same <AdditionalFiles> pattern.
The sample intentionally does not add GeneratesEvent to pump instances.
OPC 10000-3 restricts that reference to ObjectType, VariableType, and Method
declarations; runtime delivery is provided by the notifier/event-source
hierarchy and ReportEvent.
Extending the sample
When you change a published value, update
DATASHEET.mdand the constants inPumpDatasheet.cstogether —PumpDatasheetConformanceTestsfails the build otherwise.
- Add a measurement: open
PumpNodeManager.Configure.cs, add a bound updater inCreatePumpSimulation, store it inPumpSimulationState, and publish its value fromPublish. Add its engineering range toPumpDatasheet.Rangesand to section 4 of the datasheet. - Add an alarm: create it from the typed
Eventsbuilder inCreatePumpSimulationand wire the triggering boolean variable via.ActivatesAlarm(...). Document its trip points in section 7 of the datasheet. - Add a nameplate field: materialise it with the generator-emitted
AddXxx(context)helper inPumpNodeManager.MaterialiseNameplate, assign its value inWithIdentification, and add the row to section 2 of the datasheet. - Add pumps: pass
--pumps N(or setpumps=N) to materialise N identical simulatedPumpTypeinstances.PumpNodeManagerdemonstrates the hand-written fluent style by creating every pump, wiring Identification, Measurements, Supervision, Maintenance, engineering units, alarms, history, and the simulation callbacks.Program.cskeeps the declarative DI style by wrapping each generated pump withctx.TopologyElement<PumpState>(...)and adding the ad-hoc Diagnostics functional group throughConfigureDevicesFor<PumpNodeManager>.CreatePumpAsyncregisters each new instance with the shared simulation, so pumps created after startup join the same live tick.
NativeAOT publishing
cd samples/PumpDeviceIntegrationServer
dotnet publish -c Release -r win-x64
The pump server publishes cleanly under NativeAOT — no trim or AOT warnings — because every fluent extension is reflection-free and the generated model factories are statically rooted.
See also
DATASHEET.md— the official-style PumpX-2000 product datasheet the simulation implements.docs/DeviceIntegration.md— full developer guide for the DI library trio (device builder, hosting integration, lock service, software-update package store, client helpers).