Files
Marc Schier 2b44fcad22 Robot intent: a task-level command API with a Part 10 lifecycle (#4165)
# 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
2026-08-08 09:08:40 +02:00
..

GeneratorServer

A self-contained OPC UA server implementing the Generators companion specification (http://opcfoundation.org/UA/Generators/) end to end: N simulated generating sets, a datasheet-driven simulation, DI and Machinery integration, and a per-set OpenUSD twin.

It is the sibling of PumpDeviceIntegrationServer, and the two are composed into one scene by SiteCompositionServer.

dotnet run --project samples/GeneratorServer -- \
    --host localhost --port 62543 --generators 4
Argument Default Meaning
--generators N 2 How many sets to simulate (1…100)
--faults true Let the first set develop faults on a slow rotation
--port 62543 Endpoint port
--host 0.0.0.0 Bind host

The machine

A fictitious SimGen Systems GenX-500 — 400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole, on a 12.5 L turbocharged inline-six. Full parameters, curves and trip points are in DATASHEET.md, and every one of them is a constant in GeneratorDatasheet.cs, so the document, the address space and the simulation cannot drift apart.

The model

graph TD
  GS["GeneratorSet_n : GeneratorSetType<br/>(DI DeviceType)"]
  GS --> ID["Identification<br/>(Machinery)"]
  GS --> BB[MachineryBuildingBlocks]
  GS --> ST["OperatingState<br/>GeneratorStateMachineType"]
  GS --> ENG["Engine<br/>speed, oil, coolant, exhaust, fuel, hours"]
  GS --> ALT[Alternator]
  ALT --> L1[L1]
  ALT --> L2[L2]
  ALT --> L3[L3]
  GS --> CTL[Controller]
  GS --> FUEL[FuelSystem]
  GS --> COOL[CoolingSystem]
  GS --> LUBE[LubricationSystem]
  GS --> BATT[StartingSystem]

GeneratorSetType derives from the DI DeviceType, so — unlike the pump sample, whose PumpType derives from the Machinery MachineType — sets are created through CreateDeviceAsync<TDevice> and get DI registration and topology wiring for free.

Most telemetry in the specification is optional, so the type fits both a bare air-cooled residential set and a fully instrumented industrial one. This sample models the instrumented case and opts in explicitly, including the L2 and L3 phases (optional because single-phase sets populate only L1).

The simulation

Load fraction is the only independent variable. Everything else follows from the curves in the datasheet, so the published values reconcile by construction rather than by coincidence:

V̇_f(x) = 3.67 + 100·x                 [L/h]
η(x)   = P(x) / (V̇_f(x) · ρ · LHV)
S      = P / PF        I = S / (√3 · V_LL)        f = N · p / 120

Verified live against a running server at 87.17 % load:

Published Value Check
Real power 348.69 kW 0.8717 × 400 kW
Voltage / current / PF 400 V / 629.11 A / 0.8 √3·V·I·PF = 348.69 kW
Fuel rate 90.84 L/h 3.67 + 100×0.8717
Efficiency 38.9 % P / (V̇·ρ·LHV)
Frequency 49.97 Hz 1498.95 min⁻¹ × 4 / 120, with governor droop

Each set carries its own state and a phase-shifted duty point, so no two sets in a plant report the same numbers — a four-set run showed 81.9 / 66.3 / 55.1 / 56.6 %.

Control: state machine, protections, methods

The three surfaces the specification defines for controlling a set are all wired, and all three are driven from one decider — the simulation's operating state. A state machine driven independently of the physics eventually reports a machine as running while the simulation says it is stopped, and nothing at run time notices.

OperatingState

GeneratorStateMachineType is mandatory on the type, so it already exists; the sample attaches behaviour to it in lifecycle mode rather than defining states:

Off ──▶ Ready ──▶ Starting ──▶ Warmup ──▶ Running ──┬──▶ Loaded ──▶ Cooldown ──▶ Stopping ──▶ Off
                     │                              └──▶ Synchronizing ──▶ Paralleled ──▶ Loaded
                     ▼
                   Fault ──▶ Off                 any running state ──▶ Fault / EmergencyStopped ──▶ Off

Twelve states, twenty-two declared transitions. The first set energises a dead bus and closes straight onto it; every other set has to match voltage, frequency and phase to a bus that is already live, which is what puts Synchronizing and Paralleled on the path. Without that they would be states the model declares, the diagram draws and a client can never observe — so a test drives the simulation and asserts every declared state is actually entered.

CurrentState carries the readable name and its Id property, because a client that gets a name it cannot resolve to a state node is no better off than one that got nothing.

The states and transition numbers live in GeneratorStateMap.cs, apart from the node manager, because they are a statement about the model rather than about how this server publishes it — and because a test then holds them against GeneratorSimulation.IsLegalTransition in both directions.

Protection alarms

Four GeneratorProtectionAlarmType instances per set — one per protection function, not one instance whose ProtectionFunction changes. A set can trip on low oil pressure and overspeed in the same moment and an operator needs to see both, which is also how a real control panel annunciates.

Alarm Supervises Trips Class
LowOilPressureAlarm LubricationSystem/OilPressure < 1.7 bar shutdown
HighCoolantTemperatureAlarm Engine/CoolantTemperature > 98 °C shutdown
OverspeedAlarm Engine/Speed > 1725 min⁻¹ shutdown
OverloadAlarm Alternator/LoadPercent > 110 % warning

OffNormalAlarmType takes healthy as normal, so each instance carries InputNode pointing at the variable it actually supervises — otherwise a client can see that something tripped but not what was being watched.

A healthy set cannot trip. The datasheet curves are bounded well inside every trip point — that is what a datasheet means — so a plant running to spec would leave all four alarms, the shutdown class, ResetFaults and the whole Fault branch of the state machine as code that never runs. The first set therefore develops a fault on a slow rotation (CoolingFailure → OilPressureLoss → Overload → GovernorFailure), trips, annunciates, shuts down and recovers. It is the first set rather than the last because that is the machine nearest the hero camera in the 3D twin — an alarm nobody can see demonstrates nothing. A fault deviates the measurement from the curve, which is what a real fault does, so the datasheet identities keep holding for every healthy set. Pass --faults false for a purely healthy plant.

The trap that cost the most here. ProtectionFunction is mandatory on the type, so the generated factory materialises it. IsShutdown and SubsystemName are optional and it does not. Calling CreateOrReplaceIsShutdown(...).Value = x on its own appears to work — the child exists, it is returned by GetChildren, it holds the value — but it has no ReferenceTypeId, so there is no reference for a browse to follow and the property is simply absent from every client's view. Optional members must be opted into with AddXxx(context) first. This was found by reading the running server with a client, not by reading the code, because the code looks correct. GeneratorProtectionAlarmNodeTests now asserts that every published member carries a reference a client can follow.

A shutdown trip is applied after every protection has been evaluated, not inside the loop. Stopping the set mid-loop makes its remaining conditions read healthy, so a set that lost oil pressure and overspeed in the same moment would annunciate only whichever came first in the table — exactly the case that one-alarm-per-function exists to show.

A shutdown-class alarm latches; a warning-class one follows its condition. The trip is what removes the condition — oil pressure and coolant temperature are only supervised while the engine turns — so an alarm that simply tracked its input would go active for a single tick and clear, leaving an operator with a stopped machine and no indication of why. It stays annunciated until the set leaves the shutdown state, whether through ResetFaults or the automatic recovery. This was found by watching a running server, not by a test: every unit test that checked the condition passed.

Both raising and clearing report an event. A client learns of condition state changes only through events, so clearing the node silently — which ResetFaults used to do — leaves an alarm-list client showing the condition as active and retained until it happens to issue a ConditionRefresh.

Low oil pressure is bypassed while cranking. Oil pressure has not built during a start, so supervising it there trips every set the moment it tries to run; a real set bypasses the trip for exactly this reason. This was a live defect here before the bypass was added, and a test now pins it.

Methods

Method Effect Refused when
Start Off → Ready → Starting, or Ready → Starting already running
Stop → Cooldown not running
EmergencyStop → EmergencyStopped, breaker open see below
ResetFaults clears latched alarms; Fault/EmergencyStopped → Off never
StartTest starts the set and publishes Test mode already running
SetOperatingMode writes OperatingMode mode is not declared

A refused request answers BadInvalidState rather than quietly doing nothing: a method that silently succeeds without acting is the worst of the three outcomes, because a client cannot tell it from a real success. Legality is decided in exactly one place — GeneratorSimulation.RequestState — so no caller can drive a machine from Off straight to Loaded by picking the right method.

Resetting a healthy set is a no-op success. An operator pressing reset on a running machine has not done anything wrong, and answering Bad would only train them to ignore the result.

A note on the emergency stop. The model declares an emergency stop only out of Running, Loaded and Paralleled, so this sample refuses it from Starting and Warmup. A real panel stops from anywhere. That is the specification's shape, not this sample's choice, and it is left visible — with a test that pins it — rather than papered over with an undeclared transition.

The method semantics live in GeneratorCommands.cs, expressed against the simulation rather than against address-space nodes, so they can be held to account by a test without standing up a server.

The OpenUSD twin

Each set owns a GeneratorTwin with its own prim path, signal Variables and bindings, so --generators N renders N machines that move independently. Sets lay out along +Y at 6 m pitch.

Sixteen live bindings drive the bay position, radiator fan, load and coolant gauge needles, exhaust and radiator colour, fuel-tank surface, a red alarm ring, overheat and low-oil halos at the subsystem each fault belongs to, a run lamp, and frequency / power / engine-hours / load / operating-state readouts.

Five more make the machine visibly alive rather than merely correct: both exhaust manifolds glow with exhaust temperature — those are the parts of a running engine that actually glow — the alternator carries a heat band driven by load, and both turbochargers turn. The manifolds and the stack share one source Variable, so a client asking why they are glowing reads the single value behind all three.

The fan is shown slower than it turns, deliberately. A fan on a 1500 rpm engine sweeps 9000°/s. Sampled at the tick interval that is several revolutions per sample, so the published angle jumps by a near-arbitrary amount and the blades either strobe or sit still — neither of which says "this machine is running", which is the only thing the signal exists to say. The display rate is scaled so the per-tick step stays well under the blade pitch.

Fault indicators sit clear of the machine. Both halos were originally tucked into the engine centre, which was fine against a plain block and invisible once there was a crankcase, an aftercooler and a sump in the way — and an indicator you cannot see is worse than none, because it reads as no fault. AlarmRing moved for the same reason: as a decal on the floor it was hidden by the skid and by the neighbouring machines from any operator-height camera, so it is now a beacon ring above the set, at the one height nothing else occupies.

Colour and visibility changes are both live viewport signals. Thermal bindings publish primvars:displayColor (color3f[]) for the radiator, exhaust, manifold glow and alternator heat band, and current OpenUSD packages animate those authored colours in the shipped viewer. The visibility bindings — run lamp, alarm beacon, fault halos — remain useful for binary state that must be unmistakable at a glance.

The operating-state readout is what makes an idle machine legible: without it the 3D view shows that a set is not turning but not why — Cooldown and EmergencyStopped look identical from the outside.

The machine you see

The geometry is an open-frame ~400 kW V16 genset, modelled to be recognisable rather than schematic:

  • Skid — channel-section side rails (web plus top and bottom flanges), five cross members and lifting lugs, carrying the base fuel tank
  • Engine — 60° V16: crankcase, sump, gear case and flywheel housing, two tilted heads with eight rocker covers each
  • Exhaust side — a manifold log per bank with eight risers and an elbow into each turbocharger, in scaled copper-oxide brown
  • Turbochargers — turbine housing, cartridge, compressor housing and inlet
  • Charge air — riser / crossover / drop pipes arcing over each bank into the aftercooler in the vee
  • Service side — a bank of spin-on fuel filters, oil filters, starter, charging alternator, water pump and coolant hoses
  • Alternator — drum with a ventilation slot band, both end bells, terminal box and mounting feet
  • Radiator — core plus a fin pack, bolted guard uprights, header tanks, filler cap, fan shroud and a nine-blade fan
  • Control panel — cabinet with a dark instrument fascia, display, mimic plate, keypad, emergency-stop mushroom and warning label strip

Detail is worth the bytes here: a viewport is the only place some of this sample's behaviour is legible at all, and a machine that reads as a box makes a fan that turns and a radiator that changes colour hard to interpret.

The authoring rule that matters

A prim a connector positions must declare xformOp:transform in its xformOpOrder. A connector folds Translation, Rotation and Scale into a single matrix op, and xformOpOrder is uniform — it cannot be rewritten from the stronger layer the connector edits. Naming xformOp:translate there instead makes USD discard every value in silence, and every set renders on the origin.

Indicator geometry defaults to invisible, the plant aggregation composes with Reference (not Instance, which would make descendants a shared prototype that cannot carry per-set colour or rotation), and it is not declared dynamic because the set of machines is fixed at start-up.

The geometry is generated — edit Assets/generate_generator_assets.py and re-run it; never hand-edit generator.usda or Powerhouse.usda.

Prim names in that script are load-bearing. OpenUsdBindings.cs drives Radiator/Fan, Radiator/Core, Exhaust/Stack, the two gauge needles, ControlPanel/RunLamp, FuelTank/Surface, AlarmRing and the two engine halos by path, so renaming any of them silently unbinds it — GeneratorAssetContractTests asserts every one still resolves.

The model files

Model/ vendors the Generators nodeset plus a reduced Machinery nodeset. The reduction is not cosmetic: the full official Machinery nodeset does not survive the model source generator, and it drags in the IA namespace through a single optional Stacklight member that a generator set does not have. prepare_machinery_nodeset.py derives the reduced set from the official one by whitelist, so the provenance stays checkable and the whitelist is the only thing to edit when more types are needed.

Rendering it

dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \
    --server opc.tcp://localhost:62543/GeneratorServer \
    --insecure --view

See also