# Description Adds a **Generators companion-specification sample** alongside the existing pump sample, and a **`SiteCompositionServer`** that composes several device servers into one OpenUSD scene at a supervisory level — demonstrating that servers can be aggregated without mirroring their address spaces. Three things, in dependency order: ### 1. `GeneratorServer` A self-contained server realising the draft Generators spec (`http://opcfoundation.org/UA/Generators/`) end to end: N simulated generating sets, DI + Machinery integration, a twelve-state operating state machine, four protection alarms per set, six control methods, and a per-set OpenUSD twin. The organising idea is that **load fraction is the only independent variable**. Everything else is a function of it: ``` V̇_f(x) = 3.67 + 100·x fuel rate [L/h] η(x) = P(x) / (V̇_f(x) · ρ · LHV) efficiency S = P / PF I = S / (√3·V_LL) f = N·p / 120 ``` This is not stylistic. When each measurement is an independent oscillator — as the pump sample's simulation once was — a server happily publishes a duty point no real machine could occupy, and no test can catch it because there is nothing for the values to be inconsistent *with*. Deriving them from one variable means `P = √3·V·I·PF` and `η = P/(V̇·ρ·LHV)` hold at every tick by construction. The published `DATASHEET.md`, the engineering ranges, the trip points and the simulation all read the same constants in `GeneratorDatasheet.cs`, so the document and the server cannot describe different machines. The machine is a fictitious **SimGen Systems GenX-500** (400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole). Figures are representative of a real industrial genset class; no vendor is named anywhere in the code or docs. ### 2. Cross-server composition in the connector `RemoteSessionFactory` already existed in the client library but the connector CLI never set it, so cross-server composition was unreachable. `--federate` wires it up: the connector opens a session to each server named by a component binding, discovers its representations and drives its bindings into the same stage. It is **opt-in** because the endpoint the connector dials comes from the server being rendered rather than from the operator, which makes honouring it a trust decision. Federation is best-effort per component — a subordinate that is down is logged and skipped, and the rest of the scene still renders — which is the only `src/` change in this PR. ### 3. `SiteCompositionServer` Owns no devices. It publishes a site stage plus one cross-server component binding per subordinate, carrying that server's `ComponentServerUri` and `ComponentEndpointUrl`. Nothing is mirrored, so there is no cache to invalidate and no second copy of the truth. ## Notes for reviewers **Everything interesting in this PR was found by running the servers, not by reading the code.** Six defects, none of which looked wrong in review: - `IsShutdown` and `SubsystemName` are *optional* on `GeneratorProtectionAlarmType`, so the generated factory does not materialise them. `CreateOrReplace` alone produces a child that exists, appears in `GetChildren` and holds the right value — but carries **no `ReferenceTypeId`**, so no browse can reach it. Every alarm was publishing its trip without saying whether the trip stops the machine or which subsystem to go to. `AddXxx(context)` first is what gives the child its `HasProperty` reference. - **The protections could never fire.** Every trip point sits outside the band the datasheet curves produce — that is what a datasheet *means* — so all four alarms, the shutdown class, `ResetFaults` and the whole `Fault` branch of the state machine were unreachable while the README documented them as observable. Overload was worse: the load clamp ceiling was set to exactly the trip point, making a strictly-greater-than comparison unsatisfiable. Faults are now injectable, and the first set — the one the hero camera frames — develops one on a slow rotation. - **A shutdown trip removes the condition that caused it**, because oil pressure and coolant temperature are only supervised while the engine turns. Each alarm went active for one tick and cleared, leaving an operator with a stopped machine and no indication of why. Shutdown-class alarms now latch until the set leaves the shutdown state. Every unit test that checked the *condition* passed throughout — the defect only appears when the trip and the supervision interact over time. - **`Synchronizing` and `Paralleled` were declared, drawn in the README and never entered.** The first set now energises a dead bus and closes onto it while every other set synchronises to a live one — both how a real plant parallels, and what makes the two states observable. - **The tick raced client method calls.** The simulation tick runs on a thread-pool thread while method calls arrive on request threads, and both transition sets and write the same nodes. A tick moving a set to `Cooldown` and a concurrent `EmergencyStop` could interleave the paired `CurrentState` / `CurrentState.Id` writes and leave a client with a state *name* from one transition and a state *node* from the other — the exact failure the paired write exists to prevent, reintroduced by the threading model. Both paths now take one gate. - Low oil pressure supervised from raw speed **tripped every set during cranking**, because pressure has not built yet. Now gated on `IsSpinning`, which is what a real start-up bypass does. Smaller fixes: shutdown trips are applied after the whole evaluation pass (stopping mid-loop made the remaining conditions read healthy, collapsing simultaneous trips to whichever came first in the table); `ResetFaults` reports its clears as events (a client learns of condition state changes only through events, so a silent clear leaves an alarm-list client showing it forever); the start counter moved into the transition so commanded starts are counted; the federated connector closes the remote session if the connector constructor throws between taking ownership and registering it; and `prepare_machinery_nodeset.py` now *asserts* the IA namespace is last before removing it rather than only claiming so in its docstring — removing any earlier entry would silently renumber every namespace after it and rebind their NodeIds. **A model limitation is left visible rather than papered over.** `GeneratorStateMachineType` declares an emergency stop only out of `Running`, `Loaded` and `Paralleled`, so the sample refuses `EmergencyStop` from `Starting` and `Warmup`. A real panel stops from anywhere. That is the specification's shape, not this sample's choice, so it is pinned by a test and named in the docs. **`Model/` vendors a reduced Machinery nodeset**, derived mechanically by whitelist. The full official nodeset does not survive the model source generator (`MODELGEN003`) and drags in IA through a single optional `Stacklight` member a generating set does not have. Deriving it by script keeps the provenance checkable; the whitelist is the only thing to edit when more types are needed. **Never hand-edit the generated output.** **The federated scene renders end to end.** Getting there surfaced three more defects, all of which produced a plausible-looking scene with nothing in it: the generator sample's plant aggregation was created under an already-registered `DeviceSet` and so was invisible to every client (it had *never* rendered geometry, standalone or federated); the connector's asset fetch ran on the primary session only, so composition referenced layers that were never downloaded; and both generator colour bindings wrote `primvars:displayColor` to prims that did not declare it, so the renderer rejected every update while the file still looked correct. Cross-server components compose under the subordinate's own root, so the site layer now *places* `/Plant` and `/Powerhouse` rather than leaving them stacked on the origin. **`OpenUsdRepresentation` is now mounted with `HasAddIn`, not `HasComponent`** (review feedback). The nodeset says so explicitly — *"Mounted with HasAddIn"* — but every server in the tree used plain `HasComponent`. It survived because `HasAddIn` is a *subtype* of `HasComponent`: the representation still browses, still aggregates and still drives a twin, so every functional test passed before the fix and after it, and only a conformance checker could tell. Seven mount sites corrected. `PumpDeviceIntegrationServer` and `MinimalRobotServer` had hand-rolled the mount instead of calling the shared `CreateRepresentation` helper, which is exactly how they drifted from it. Both sample E2E suites now assert the reference type across **every** discovered representation — a narrower check would have missed the plant-level representation that arrived later from master still carrying `HasComponent`. **Live colour is not achievable in the current OpenUSD viewer, and that is now documented rather than worked around.** Proven with a standalone probe: `primvars:displayColor` resolves as `color3f[]` from the `UsdGeomGprim` schema whatever the layer declares, and the `OpenUsd 0.4.0-alpha` managed API has no writer for that type. Bound `UsdPreviewSurface` materials are not shaded either, so geometry renders grey unless it also carries an explicit `displayColor` primvar. The **visibility** bindings carry no such caveat and are what the samples rely on to show state. Filed upstream as [openusd-dotnet#2](https://github.com/marcschier/openusd-dotnet/issues/2), [#3](https://github.com/marcschier/openusd-dotnet/issues/3) and [#4](https://github.com/marcschier/openusd-dotnet/issues/4), and linked from `docs/OpenUsd.md` so a reader can check whether they have since been fixed. ## Related Issues No tracking issue — this is additive sample and documentation work with a single small, self-contained change to `src/` (per-component error isolation and session-ownership hardening in `OpenUsdConnector.Composition.cs`). Happy to open one if maintainers would prefer it tracked. ## 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. ### Testing The generator fixtures live in `tests/Opc.Ua.OpenUsd.Tests/Generator` — **82 tests**, no new project. `Opc.Ua.Di.Tests` would have been the natural home next to the pump fixtures, but both device samples source-generate their own reduced `Opc.Ua.Machinery` model and expose it via `InternalsVisibleTo`, so referencing both from one assembly makes every Machinery type ambiguous (CS0433); `Opc.Ua.OpenUsd.Tests` references no other sample and already hosts `RobotAssetContractTests`. The suite runs **870** on net10.0 and net48 (784 existing + 86) and **788** on net472, where the sample does not exist and only the framework-independent asset-contract tests run — forgetting exactly that exclusion broke CI on the sibling pump PR. The tests target the things in this sample that fail *quietly*: - **Datasheet conformance** holds the model to its own claim — rated speed is `120·f/p`, rated current follows from the rating, the fuel curve reproduces the published table, and `η = P/(V̇·ρ·LHV)` reconciles at every load. Efficiency is swept across the whole simulated range to show it stays inside (0,100) and rises monotonically; a fit that goes negative somewhere in its range is a coincidence that happens to look right at the duty point, not a model. A further test reads `DATASHEET.md` and checks the document quotes the figures the server actually serves. - **Drift between the two descriptions of the state machine** — the physics' `IsLegalTransition` and the model's `GeneratorStateMap` — is checked in *both* directions. A transition the physics permits but the map lacks moves a machine without telling a client; one the map holds but the physics refuses is dead weight that looks supported. - **Reachability at run time**, not just in the declared table — the test that catches states the model declares and nothing ever enters. - **That every protection can actually fire**, which is the half of the alarm contract that "a healthy set annunciates nothing" does not cover. A **hosted end-to-end fixture** connects a real client and asserts what the address space actually exposes — the aggregation at `/Powerhouse/Generators`, its component binding, and every per-set twin. That is the only kind of test that catches the registration defect above: the node object had the right browse name, binding and asset reference, so every test that inspected objects passed while no client could see it. **Every state-machine and protection fix was checked by mutation.** Restoring the clamp ceiling, the direct `Running → Loaded` path, the old start-counter placement, or removing the alarm opt-in each makes the corresponding test fail, naming the specific defect. A test that has never been seen to fail has not been shown to work. Verified against a running plant: `Synchronizing`, `Paralleled` and `Fault` all observed on a two-set server, with `HighCoolantTemperatureAlarm` seen active on the fault subject. Method behaviour verified separately — per-set distinct `CurrentState` nodes, `EmergencyStop` → `EmergencyStopped` with `Start` then refused as `BadInvalidState`, `ResetFaults` → `Off`, `SetOperatingMode(9999)` refused with the mode unchanged, and the other sets untouched throughout. Also verified as a three-server federated stage. 0 warnings on a clean Release rebuild of both samples, the client library, the connector and the tests, across every TFM. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
11 KiB
Generators (generating sets)
The draft Generators companion specification
(http://opcfoundation.org/UA/Generators/) realised end to end by
GeneratorServer,
and composed with the pump sample by
SiteCompositionServer.
The model
A generating set is two things at once: an asset with a nameplate and a health, and a machine whose core is an engine coupled to an alternator. The specification layers on existing building blocks rather than reinventing them:
| Layer | Role |
|---|---|
| DI (OPC 10000-100) | GeneratorSetType derives from DeviceType, inheriting the nameplate and DeviceHealth; each subsystem derives from ComponentType |
| Machinery (OPC 40001-1) | Identification add-in and the MachineryItemState / MachineryOperationMode building blocks |
| Part 9 | GeneratorProtectionAlarmType, a subtype of OffNormalAlarmType |
| Part 16 | GeneratorStateMachineType, a twelve-state FiniteStateMachineType |
Composition over deep inheritance: subsystems are separate ComponentType
subtypes referenced by HasComponent, so a set is assembled from exactly the
components it has.
Source generation
The sample generates the model locally from vendored NodeSet2 files rather than referencing a library:
<AdditionalFiles Include="Model\Opc.Ua.Generators.NodeSet2.xml">
<ModelSourceGeneratorPrefix>Opc.Ua.Generators</ModelSourceGeneratorPrefix>
</AdditionalFiles>
<AdditionalFiles Include="Model\Opc.Ua.Generators.NodeSet2.csv" />
The CSV alongside each XML is the stable NodeId table. Without it the generator assigns synthetic NodeIds at compile time, which breaks byte-stable wire formats for clients that cache NodeIds across builds.
The Machinery dependency
The specification composes three Machinery types — MachineryItemState_StateMachineType,
MachineryOperationModeStateMachineType and MachineIdentificationType — that the
reduced Machinery nodeset carried by the pump sample does not define. The full
official nodeset defines them but does not survive the model source generator
(it fails with MODELGEN003), and it declares a dependency on the IA namespace
through a single optional Stacklight member that a generating set does not have.
Model/prepare_machinery_nodeset.py therefore derives a reduced-but-sufficient
set from the official nodeset by whitelist, strips the references left dangling
and drops the IA dependency. Deriving it mechanically keeps the provenance
checkable, and the whitelist is the only thing to edit when more types are needed.
Removing the IA URI is index-safe because it is the last entry in
NamespaceUris, so the Machinery (1) and DI (2) indices used throughout the file
do not move.
Simulation design
The sample's organising idea is that load fraction is the only independent variable. Everything else is a function of it:
V̇_f(x) = 3.67 + 100·x fuel rate [L/h]
η(x) = P(x) / (V̇_f(x) · ρ · LHV) efficiency
S = P / PF apparent power [VA]
I = S / (√3 · V_LL) line current [A]
f = N · p / 120 frequency [Hz]
This is not a stylistic choice. When each measurement is an independent
oscillator — as the pump sample's simulation once was — a server happily
publishes a duty point that no real machine could occupy, and no test can catch
it because there is nothing for the values to be inconsistent with. Deriving
them from one variable means P = √3·V·I·PF and η = P/(V̇·ρ·LHV) hold at every
tick by construction, and GeneratorDatasheetConformanceTests asserts exactly
that.
The published datasheet, the engineering ranges, the trip points and the simulation all read the same constants, so they cannot drift apart.
Control surfaces
The specification defines three ways to observe and command a set, and all three are driven from the same decider: the simulation's operating state. This is the design point worth copying. A state machine advanced independently of the physics that produced it will eventually report a machine as running while the simulation says it is stopped — and because both halves are internally consistent, nothing at run time notices.
GeneratorStateMachineType
Twelve states and twenty-two declared transitions, mandatory on the type. Because
the node already exists, the sample attaches behaviour to it in lifecycle mode
(StateMachineBuilder.For(machine, context)) rather than defining states — see
State machines.
The simulation raises a transition callback; the address space follows it. Both
CurrentState and its Id property are written, because a client that receives a
state name it cannot resolve to a state node is no better off than one that
received nothing.
The state and transition number tables are deliberately kept apart from the node
manager, in GeneratorStateMap, and a test holds them against the simulation's
own legality function in both directions: a transition the physics permits but
the map lacks moves a machine without telling a client; one the map holds but the
physics refuses is dead weight that looks supported.
GeneratorProtectionAlarmType
One instance per protection function rather than a single instance whose
ProtectionFunction changes. A set can trip on low oil pressure and overspeed in
the same moment, and collapsing them loses the second; it is also how a real
control panel annunciates.
Because OffNormalAlarmType takes healthy as the normal state, each instance
carries InputNode pointing at the variable it supervises. Without it a client can
see that something tripped but not what was being watched.
Optional members must be opted into. ProtectionFunction is mandatory on the
type and is materialised by the generated factory; IsShutdown and SubsystemName
are optional and are not. Writing to an optional member with CreateOrReplace
alone produces a child that exists, appears in GetChildren and holds the value —
but carries no ReferenceTypeId, so no browse can reach it and the property is
absent from a client's view. Call AddXxx(context) before writing. The failure is
silent in both directions: the code looks right, and the server answers reads on
everything it does publish, so only a client comparing against the type definition
notices.
A simulation that cannot leave its healthy band cannot exercise its
protections. The datasheet curves are bounded inside every trip point by
construction, so a faithful simulation of a healthy machine leaves the entire
protection path — alarms, shutdown class, ResetFaults, the Fault branch of the
state machine — as dead code, while the documentation describes it as working. The
sample therefore injects a deviation into the measurement rather than the curve,
which is what a real fault is, and rotates it so a long run exercises every
protection. The lesson generalises: when the model says a thing cannot happen, the
code that handles it needs a deliberate way to be reached, or it is untested.
Trip after evaluating, not during. Stopping the set inside the evaluation loop makes its remaining conditions read healthy, so simultaneous trips collapse to whichever protection came first in the table — defeating the reason for having one alarm instance per function.
A shutdown protection must latch. The trip removes the condition that caused it, because the supervised quantities are only meaningful while the machine runs. An alarm that tracks its input therefore annunciates for one tick and clears, which tells an operator that something stopped the set but not what. Latch until the set leaves the shutdown state. This one is easy to miss in review and in unit tests — the condition logic is correct in isolation; the defect only appears when the trip and the supervision interact over time.
Clearing is an event, not just a state change. A client learns of condition
state changes only through events. Clearing an alarm node without reporting leaves
an alarm-list client displaying it as active and retained until a
ConditionRefresh.
Trip conditions read the hysteresis the simulation already applies, so alarms latch and clear cleanly rather than chattering on the threshold, and events are reported only on change. A shutdown-class trip stops the machine; a warning does not — reporting a trip without stopping the set would publish a generator that is on fire and still loaded.
One trap worth naming: supervising low oil pressure during cranking trips every set the moment it tries to start, because pressure has not built yet. Real sets bypass that trip for exactly this reason.
Methods
Legality is decided in exactly one place, so no caller can drive a machine from
Off straight to Loaded by picking the right method. A refused request answers
BadInvalidState rather than silently doing nothing — a method that appears to
succeed without acting is indistinguishable from a real success, which is worse
than an honest refusal.
The method semantics are expressed against the simulation rather than against address-space nodes, which keeps each handler down to one line and lets the behaviour be tested without standing up a server.
Concurrency
The simulation tick runs on a thread-pool thread while client requests are served on their own threads, and both drive state transitions and write the same address-space nodes. They are serialised by a single gate held across the whole tick and across every method handler.
This matters more than it looks. Without it, a tick moving a set to Cooldown and
a concurrent EmergencyStop both fire the state-change callback, and the paired
CurrentState / CurrentState.Id writes interleave — leaving a client with a
state name from one transition and a state node from the other. That is the
precise failure the paired write exists to prevent, reintroduced by the threading
model rather than by the write itself. One gate for the whole plant rather than one
per set: a tick is microseconds of arithmetic, so the contention is irrelevant, and
a single gate cannot be acquired in the wrong order.
Cross-server composition
SiteCompositionServer demonstrates composing several servers at a supervisory
level. It owns no devices; it publishes a site stage and one cross-server
component binding per subordinate, carrying that server's ComponentServerUri
and ComponentEndpointUrl:
Opc.Ua.OpenUsd.Connector --server <site> --federate --view
With --federate the connector opens a session to each named server, discovers
its representations and drives its bindings into the same stage — one scene, live
machines, three servers.
Nothing is mirrored. The site server never proxies a subordinate's address space, so there is no cache to invalidate and no second copy of the truth.
--federate is opt-in because the endpoint the connector dials comes from the
server being rendered rather than from the operator, which makes honouring it a
trust decision. Federation is also best-effort per component: a subordinate that
is down is logged and skipped, and the rest of the scene still renders.