Files
UA-.NETStandard/samples/GeneratorServer
Marc SchierandCopilot ca89ac85a7 Add a Generators companion-spec sample and compose servers at a site level (#4180)
# 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
2026-08-07 10:48:02 +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 changes may not be visible in every viewer. Several bindings publish colour (radiator, exhaust, manifold glow, alternator heat band). Whether those land depends on the renderer: primvars:displayColor resolves as color3f[] from the UsdGeomGprim schema whatever the layer declares, and a client that cannot write that exact type cannot animate it — which is the case for the viewer this sample ships against, tracked upstream as openusd-dotnet#2. The visibility bindings — run lamp, alarm beacon, fault halos — carry no such caveat and are the reliable way to see state change in a viewport.

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