Skip to content

Add a Generators companion-spec sample and compose servers at a site level - #4180

Open
marcschier wants to merge 22 commits into
masterfrom
marcschier/generators-sample-and-scada-composition
Open

Add a Generators companion-spec sample and compose servers at a site level#4180
marcschier wants to merge 22 commits into
masterfrom
marcschier/generators-sample-and-scada-composition

Conversation

@marcschier

@marcschier marcschier commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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, #3 and #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

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • 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/Generator82 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, EmergencyStopEmergencyStopped with Start then refused as BadInvalidState, ResetFaultsOff, 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.

marcschier and others added 10 commits August 4, 2026 09:41
Groundwork for a generator-set sample alongside the pump one: the
Generators companion specification model, source-generated locally the
way the pump sample generates Pumps.

The Machinery dependency needed work. The reduced Machinery nodeset the
pump sample carries defines the identification types only, and the
Generators model additionally composes MachineryItemState and
MachineryOperationMode and specialises MachineIdentificationType. The
full official Machinery nodeset supplies those but does not survive the
model source generator - it fails with MODELGEN003 - and it drags in the
IA namespace through a single optional Stacklight member that a generator
set does not have.

So the reduced set is now derived from the official nodeset by a script
that keeps a whitelist of types plus their descendants, 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 last in NamespaceUris, so the Machinery and DI
indices used throughout the file do not move.

The generated surface matches the specification: GeneratorSetState with
Engine, Alternator, phases, controller and the balance-of-plant
subsystems, the twelve-state operating state machine, and the data types
- 8 operating modes, 64 protection functions, 5 application ratings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The simulation needs a single source of truth, so the datasheet comes
before the server that publishes it.

DATASHEET.md is a vendor-style document for a fictitious 400 kW prime /
440 kW standby, 50 Hz, four-pole set. Its parameters are ordinary
engineering values for that class of machine, picked so the simulation is
physically self-consistent rather than to describe any real product; the
document says so plainly at the top. 50 Hz and 1500 min-1 also put it in
the same electrical world as the 50 Hz pump hall, which matters once a
site composes both.

GeneratorDatasheet.cs carries the same numbers as constants, and derives
rather than tabulates anything that can be derived: rated speed is
120*f/p, rated current is S/(sqrt(3)*V_LL), and energy per litre comes
from the fuel density and heating value. A derived value cannot drift
away from what it is derived from.

Load fraction is the only independent variable. Fuel rate is the affine
map 3.67 + 100x L/h, and efficiency is defined as P/(Vdot*rho*LHV) rather
than tabulated, so the published efficiency always reconciles with the
published power and fuel rate. Checked across the range: efficiency runs
29.7% at ten percent load to 39.1% at full load, monotonic and always
inside (0,100), and P = sqrt(3)*V*I*PF holds to machine precision.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Builds the server on top of the vendored model: N generator sets from
--generators, a curve-driven simulation, and a per-set 3D twin.

GeneratorSetType derives from the DI DeviceType, so unlike the pump
sample - whose PumpType derives from the Machinery MachineType and
cannot use the DI helpers - 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 residential set and a fully instrumented industrial one. This
sample models the instrumented case and opts in explicitly rather than
assuming the factory materialises it; that includes the L2 and L3
alternator phases, which are optional because single-phase sets populate
only L1.

The simulation derives everything from one independent variable. Load
fraction picks the fuel rate off the affine map, and power, apparent
power, per-phase voltage and current, frequency, efficiency and the
thermal states all follow from it, so the published values reconcile by
construction. Verified live against a running server: at 87.17% load the
set reports 348.69 kW, 400 V, 629.11 A at 0.8 PF - sqrt(3)*V*I*PF gives
348.69 kW - drawing 90.84 L/h, which is exactly 3.67 + 100x, for 38.9%
efficiency. Four sets reported four different duty points and their bay
positions came out 6 m apart as configured.

The twin follows the authoring rule that cost the pump sample dearly:
any prim a connector positions declares xformOp:transform, because
Translation, Rotation and Scale fold into one matrix op and xformOpOrder
is uniform. Naming xformOp:translate there instead makes USD discard
every value in silence and stack every set on the origin. Indicator
geometry defaults to invisible, the plant aggregation composes with
Reference rather than Instance so each set can carry its own colour and
rotation, and it is not declared dynamic because the set is fixed at
start-up. The geometry is generated by a script; never hand-edit the
layers.

Known gap: the DI nameplate properties are materialised but their values
do not stick - they read BadNotReadable - because WithIdentification
runs before the device is registered. The measurement surface is
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The nameplate properties browsed but every read came back
BadNotReadable, so the datasheet identity never reached a client.

Two causes. The identification builder registers each property as it
creates it, so writing the nameplate before the set subtree was
registered let the later registration replace those nodes with freshly
materialised, valueless ones; the nameplate is now written after
registration. And properties the generated factory had already
materialised keep the declaration's access level, which grants no read -
only the ones the builder created itself came out readable, which is why
ProductCode worked while Manufacturer did not. Those are now opened
explicitly.

Verified against a running server: Manufacturer, Model, SerialNumber,
HardwareRevision, SoftwareRevision and ProductInstanceUri all read Good,
and the serial numbers are per unit - SG-500-001, -002, -003.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The client library has supported cross-server composition since the
binding work landed - a component binding can name another server's
endpoint, and the connector opens a session there and drives that
server's bindings into the same stage - but the command-line connector
never supplied the session factory, so the feature was unreachable from
the tool that demonstrates it.

Wire it up behind --federate. Opt-in is the point rather than a
convenience: the endpoint to connect out to comes from the server being
rendered, not from the operator, so honouring it is a trust decision.
The library is already fail-closed - no factory, no federation - and the
tool now mirrors that. Federated sessions are negotiated exactly the way
the primary session is, so composing a subordinate server cannot quietly
downgrade security, and the connector owns and closes them.

Also registers the generator sample in UA.slnx so CI builds it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Adds SiteCompositionServer: a server that owns no devices and instead
says which other server owns what. It publishes a site shell - ground,
buildings, lighting and a control-room camera - and one cross-server
component binding per subordinate, each carrying that server's endpoint.
A connector run with --federate opens a session to each named server and
drives its bindings into the same stage.

Nothing is mirrored. The site server never proxies a subordinate address
space; it only advertises where the machines are and lets the connector
talk to each owner directly, so there is no cache to invalidate and no
second copy of the truth.

Federation is now best-effort per component. A subordinate is an
independent process that can be down or refuse a session, and until now
one unreachable server threw straight out of StartAsync and took the
whole stage with it - the site rendered nothing because one of two
device servers was missing. The placeholder prim is composed before the
session is attempted, so a failure now costs only that server's machines
and is logged as such.

Verified end to end against three servers: the single override layer
carries the pump server's impeller rotation and casing colour under
/Plant, the generator server's two sets under /Powerhouse with frequency,
real power, engine hours, fan rotation and thermal colour, and the site
shell and camera from the site server itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The two new samples targeted net48 but used two APIs that are not there:
Math.Clamp, which arrived after .NET Standard 2.0, and the
string.Create overload that takes an interpolated string handler. The
first becomes a small local helper rather than conditional compilation
at four call sites; the second becomes FormattableString.Invariant,
which reads the same and works everywhere. Both samples now build clean
on every target framework.

Adds READMEs for both, and corrects the OpenUSD samples list: it still
credited the pump sample with cross-server components, which were
removed from it - SiteCompositionServer is where that now lives.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Twenty tests over the two things in this sample that fail quietly.

The datasheet tests hold the model to its own claim that load fraction
is the only independent variable: rated speed is 120*f/p, rated current
comes from the rating, the fuel curve reproduces the published table, and
eta = P/(Vdot*rho*LHV) reconciles at every load. Efficiency is also 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 last test reads DATASHEET.md and checks the document quotes the same
figures the server serves, because a sample whose datasheet describes a
different machine than it simulates is worse than no datasheet.

The asset tests pin the USD authoring contract: positioned prims declare
xformOp:transform, indicators default to invisible, every bound prim
exists, and the component asset never references the stage that composes
it. Each of those fails silently at runtime - the geometry just does not
move, with no error anywhere.

The project excludes the sample reference and the datasheet fixture on
net472, where the samples do not exist. Missing exactly that exclusion
broke CI on the sibling pump PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Explains why the Machinery dependency needed surgery, why load fraction
is the only independent variable in the simulation, and how the
supervisory server composes several servers into one scene.

The simulation rationale is the part worth writing down: when each
measurement is an independent oscillator, a server publishes duty points
no real machine could occupy and no test can catch it, because there is
nothing for the values to be inconsistent with.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The simulation already knew what state each set was in and which
protections had tripped; none of it reached a client. This connects the
three surfaces the specification defines for that, all driven from one
decider - the simulation - because a state machine advanced independently
of the physics that produced it eventually reports a machine as running
while the physics say it is stopped, and nothing at run time notices.

OperatingState is attached in lifecycle mode, since the node is mandatory
and already exists. Both CurrentState and its Id are written: a client that
gets a state name it cannot resolve to a state node is no better off than
one that got nothing. The state and transition tables move out of the node
manager into GeneratorStateMap - they are a statement about the model, not
about how this server publishes it - and a test holds them against
IsLegalTransition 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.

Four protection alarms per set, one per protection function rather than a
single instance whose ProtectionFunction changes, because a set can trip on
low oil pressure and overspeed in the same moment and an operator needs to
see both.

Two defects found by reading the running server with a client rather than
by reading the code, since in both cases the code looks correct:

IsShutdown and SubsystemName are optional on the type, so the generated
factory does not materialise them. CreateOrReplace alone produces a child
that exists, appears in GetChildren and holds the 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, and the test now asserts that every published member carries a
reference a client can follow rather than asserting the assignment.

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 set's start-up bypass does.

Method semantics move into GeneratorCommands, expressed against the
simulation instead of address-space nodes, so each handler is one line and
the behaviour can be tested without standing up a server. A refused request
answers BadInvalidState: a method that silently succeeds without acting is
indistinguishable from a real success, which is worse than an honest
refusal. Resetting a healthy set is a no-op success, because an operator
pressing reset on a running machine has not done anything wrong.

The emergency stop is left refusing from Starting and Warmup. A real panel
stops from anywhere, but the model declares only RunningToEmergencyStopped,
LoadedToEmergencyStopped and ParalleledToEmergencyStopped. That is the
specification's shape rather than this sample's choice, so it is pinned by
a test and named in the docs instead of papered over with an undeclared
transition.

Also fixes a CA1873 warning: the state-change log took a string, so
to.ToString() was evaluated whether or not the level was enabled.

54 new tests (74 total, net10.0 and net48; net472 keeps the sample
exclusions). Both drift tests were checked by mutation - removing one map
entry and removing the alarm opt-in each make them fail. Verified live
against a three-set server: per-set distinct CurrentState nodes,
EmergencyStop -> EmergencyStopped with Start then refused,
ResetFaults -> Off, Start -> Starting, SetOperatingMode(9999) refused with
the mode unchanged, and the other two sets untouched throughout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Copilot AI review requested due to automatic review settings August 4, 2026 11:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds two new companion-spec samples (Generators device server + a supervisory “site composition” server) and extends the OpenUSD connector to optionally federate cross-server component bindings, enabling one composed stage driven from multiple OPC UA servers.

Changes:

  • Added GeneratorDeviceIntegrationServer sample (Generators model, datasheet-driven simulation, OpenUSD twin) plus a new Opc.Ua.Generators.Tests suite.
  • Added SiteCompositionServer sample that publishes a site shell and cross-server component bindings for subordinate servers.
  • Wired up --federate in Opc.Ua.OpenUsd.Connector and made cross-server federation best-effort per component in OpenUsdConnector.Composition.

Reviewed changes

Copilot reviewed 45 out of 46 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
UA.slnx Adds the new samples and Generators test project to the solution.
tools/Opc.Ua.OpenUsd.Connector/Program.cs Documents new --federate CLI option.
tools/Opc.Ua.OpenUsd.Connector/OpenUsdConnectorRunner.cs Implements --federate by supplying RemoteSessionFactory and passing connector options through runner paths.
src/Opc.Ua.OpenUsd.Client/OpenUsdConnectorLog.cs Adds a warning log for failed cross-server federation.
src/Opc.Ua.OpenUsd.Client/OpenUsdConnector.Composition.cs Makes cross-server federation best-effort (per-component error isolation).
tests/Opc.Ua.Generators.Tests/Opc.Ua.Generators.Tests.csproj New test project for Generators sample, with net472 conditional exclusions for sample reference.
tests/Opc.Ua.Generators.Tests/SimulationHarness.cs Test harness to exercise simulation logic without starting a full server.
tests/Opc.Ua.Generators.Tests/GeneratorStateMachineTests.cs Validates state/transition map alignment with simulation legality and behavior.
tests/Opc.Ua.Generators.Tests/GeneratorProtectionTests.cs Validates protection definitions/trip semantics and key bypass behavior.
tests/Opc.Ua.Generators.Tests/GeneratorProtectionAlarmNodeTests.cs Verifies optional members are materialized/browsable when opted-in.
tests/Opc.Ua.Generators.Tests/GeneratorMethodTests.cs Validates command semantics and argument decoding.
tests/Opc.Ua.Generators.Tests/GeneratorDatasheetConformanceTests.cs Pins constants/curves against the datasheet and physics identities.
tests/Opc.Ua.Generators.Tests/GeneratorAssetContractTests.cs Pins USD authoring contract required for connector-driven transforms/visibility.
samples/SiteCompositionServer/SiteCompositionServer.csproj New supervisory server sample project and embedded site USD asset.
samples/SiteCompositionServer/SiteNodeManager.cs Implements site topology, OpenUSD facility, and cross-server component bindings.
samples/SiteCompositionServer/Program.cs Hosts the site server and configures subordinate endpoints via options.
samples/SiteCompositionServer/README.md Documents cross-server composition architecture and how to run it.
samples/SiteCompositionServer/Properties/AssemblyInfo.cs Sets CLS compliance attribute for the sample assembly.
samples/SiteCompositionServer/Assets/Site.usda New site shell USD layer with placeholders and camera/light rig.
samples/GeneratorDeviceIntegrationServer/GeneratorDeviceIntegrationServer.csproj New generator device server sample project and model source-gen inputs.
samples/GeneratorDeviceIntegrationServer/Program.cs Hosts generator server and configures generator count via options.
samples/GeneratorDeviceIntegrationServer/README.md Documents model, simulation approach, controls, and OpenUSD twin.
samples/GeneratorDeviceIntegrationServer/DATASHEET.md Published datasheet aligned with simulation/constants and pinned by tests.
samples/GeneratorDeviceIntegrationServer/EventIds.cs Centralized event-id offsets for source-generated logs in sample assembly.
samples/GeneratorDeviceIntegrationServer/GeneratorCommands.cs Defines method semantics over the simulation (testable without server).
samples/GeneratorDeviceIntegrationServer/GeneratorStateMap.cs Maps simulation states/transitions to model state machine IDs.
samples/GeneratorDeviceIntegrationServer/GeneratorStateMachine.cs Wires simulation-driven state machine + method nodes into address space.
samples/GeneratorDeviceIntegrationServer/GeneratorProtectionAlarms.cs Builds protection alarms, opts in optional members, and raises events.
samples/GeneratorDeviceIntegrationServer/GeneratorNodeManager.Configure.cs Wires simulation tick, variable bindings, and protection evaluation.
samples/GeneratorDeviceIntegrationServer/OpenUsdBindings.cs Declares live bindings driving each generator twin in USD.
samples/GeneratorDeviceIntegrationServer/Assets/Powerhouse.usda New generator “powerhouse” USD root layer.
samples/GeneratorDeviceIntegrationServer/Assets/generate_generator_assets.py Script to generate the generator USD assets deterministically.
samples/GeneratorDeviceIntegrationServer/Model/prepare_machinery_nodeset.py Script to derive a reduced Machinery nodeset by whitelist.
samples/GeneratorDeviceIntegrationServer/Model/Opc.Ua.Machinery.NodeSet2.csv Stable NodeId table for the reduced Machinery model used by sample.
samples/GeneratorDeviceIntegrationServer/Properties/AssemblyInfo.cs Adds InternalsVisibleTo for Generators tests and CLS compliance.
docs/README.md Links new Generators documentation page.
docs/OpenUsd.md Updates sample list to include generator and site composition samples.
docs/Generators.md New documentation for Generators sample and cross-server composition.
Suppressed comments (1)

samples/SiteCompositionServer/SiteNodeManager.cs:493

  • A new source-generated log message is needed to support error logging without calling ILogger.LogError directly (see the catch block in MaterialiseOpenUsdFacilityAsync). Add a [LoggerMessage] method here and use it from the catch site.

Comment thread samples/SiteCompositionServer/SiteNodeManager.cs Outdated
Comment thread src/Opc.Ua.OpenUsd.Client/OpenUsdConnector.Composition.cs
Comment thread samples/GeneratorServer/Generators.md
Comment thread samples/GeneratorServer/GeneratorServer.csproj
Comment thread tests/Opc.Ua.Generators.Tests/Opc.Ua.Generators.Tests.csproj Outdated
marcschier and others added 4 commits August 4, 2026 14:36
Code review of the branch found nine defects. Six were real, and the two
worst were both cases of code that looks correct and never runs.

The protections could not 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 behaviour. Overload was worse than unreachable: the load clamp
ceiling was set to exactly the trip point, so a strictly-greater-than
comparison could never be true. Faults are now injectable and the last set
develops one on a slow rotation, so the alarm path actually runs. A fault
deviates the measurement from the curve, which is what a real fault is, so
the datasheet identities keep holding for every healthy set.

Watching a running server then showed the alarms still were not visible: a
shutdown trip removes the very 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 by the model, 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, which is both how
a real plant parallels and what makes the two states observable.

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 with
no synchronisation. 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.

Also: a shutdown trip is applied after every protection is evaluated rather
than inside the loop, since stopping the set mid-loop made its remaining
conditions read healthy and collapsed simultaneous trips to whichever came
first in the table; ResetFaults reports its clears as events, because a
client learns of condition state changes only through events and a silent
clear leaves an alarm-list client showing the condition forever; the start
counter moved into the transition so commanded starts are counted, not just
automatic ones; 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 really is
last before removing it, rather than only claiming so in its docstring -
removing any earlier entry would silently renumber every namespace after it.

Eight new tests (82 total). Each of the state-machine and protection fixes
was checked by mutation: restoring the clamp ceiling, the direct
Running->Loaded path, or the old start-counter placement each makes the new
tests fail, naming the specific defect. Verified against a running plant:
Synchronizing, Paralleled and Fault all observed, and
HighCoolantTemperatureAlarm seen active on the fault subject.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Five review threads.

Renamed samples/GeneratorDeviceIntegrationServer to samples/GeneratorServer,
including the assembly, package id and application name, so the endpoint
path a client dials matches the project. Moved docs/Generators.md next to
the sample it documents and repointed the links in both directions.

Dropped the tests/Opc.Ua.Generators.Tests project and moved its fixtures
into tests/Opc.Ua.OpenUsd.Tests/Generator.

Opc.Ua.Di.Tests was the first choice, since it already hosts the pump
sample's fixtures and a generating set is a DI device. It does not work:
both device samples vendor and source-generate their own reduced
Opc.Ua.Machinery model and expose it through InternalsVisibleTo, so
referencing them from one assembly makes every Machinery type ambiguous
(CS0433) and breaks the existing pump tests. An extern alias on the project
reference would be the textbook fix, but Aliases metadata on a
ProjectReference is not honoured by the SDK here. Opc.Ua.OpenUsd.Tests
references no other sample, so there is no collision, and it already hosts
RobotAssetContractTests - the same kind of sample-facing asset contract
test - so the precedent is there. The generator asset is linked under
Assets/Sample to match that convention rather than into the frozen
reader fixtures beside it.

The model namespace is imported as an alias in the moved fixtures. The old
test namespace nested inside Opc.Ua.Generators, so the generated types
resolved implicitly; under the new namespace an unqualified import would
make Namespaces, ReferenceTypeIds and VariableTypeIds ambiguous against
Opc.Ua's.

SiteNodeManager no longer calls ILogger.LogError directly. It now uses a
source-generated OpenUsdFacilityFailed message, and the event ids are
allocated from a SiteCompositionServerEventIds offset rather than the bare
literal 0, matching the convention the generator sample already follows.

The CA1031 suppression in the federation path keeps its broad catch and
gains the required TODO plus the reasoning: RemoteSessionFactory is a
public extension point, so a caller-supplied implementation can throw
anything, and letting an unanticipated type escape would take the whole
stage down for one unreachable subordinate - the failure the handler exists
to prevent. Cancellation is re-thrown above it, so nothing is swallowed.
It can be narrowed once the factory documents what it may throw.

Verified: 0 warnings on a Release build of both samples, the client library,
the connector and both test projects across every TFM. Opc.Ua.OpenUsd.Tests
866 pass on net10.0 and net48 (784 existing + 82 generator) and 788 on
net472, where the sample does not exist and only the asset contract tests
run. Opc.Ua.Di.Tests unchanged at 402. The renamed server starts and serves
opc.tcp://localhost:62847/GeneratorServer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Running the three-server demo end to end surfaced three defects, each of
which produced a plausible-looking scene with nothing in it.

The generator sample never rendered any geometry, in any configuration.
Its plant aggregation representation is created as a child of DeviceSet,
which was registered long before, so the node existed only as a C# object
hanging off an already-registered parent: RegisterInDiscovery listed it but
no client could resolve or browse it. A connector therefore never saw the
aggregation and never authored the reference arcs, and the sets rendered as
live values on prims with nothing behind them - an empty powerhouse
reporting 250 kW. This reproduced standalone, so it was never working. The
pump sample registers its equivalent subtree; this one now does too.

A federated stage fetched no subordinate geometry. The connector's asset
fetch ran on the primary session only, so composition authored reference
arcs against layers that were never downloaded, and stage.usda sublayered
only the primary root. Every subordinate's machines resolved to nothing.
The fetch now follows cross-server components into the same cache
directory - a ComponentAssetReference is a plain relative identifier, so it
only resolves if the subordinate's layer sits beside the primary's - and
every root layer is sublayered rather than just the first. One layer became
six.

Both generator colour bindings failed at runtime. They write
primvars:displayColor, but the radiator core and exhaust stack carried only
a material binding, so the renderer rejected every update as "not a vec3f
array". The value still reached the override layer, so the file looked
correct while the colour never moved in a viewport, and the machines sat
permanently at the last authored value - bright red, as if the whole plant
were alarming. Both prims now declare the attribute they are driven
through.

The site scene is also laid out to be looked at. Cross-server components
compose under the subordinate's own root, so /Plant and /Powerhouse both
sat on the world origin and interpenetrated while the site slabs stood
empty beside them. The Powerhouse root now declares xformOp:transform -
xformOpOrder is uniform and cannot be introduced from a stronger layer, so
a prim another layer may place has to declare the op itself - and the site
layer positions both subordinates, drops the pump slab that had nothing
standing on it, tightens the ground, and replaces the satellite viewpoint
with an operator standing off the front corner with both rows in frame.

The asset contract test now pins the displayColor declarations, and fails
without them. The registration defect is not covered: catching it needs an
end-to-end fixture that hosts the server and inspects what a client can
actually browse, which is the only thing that would have caught it - every
existing test passed throughout.

867 tests pass; 0 warnings across every TFM.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The defect that made the generator sample render nothing was invisible to
every unit test: the plant aggregation existed as a node object with the
right browse name, the right binding and the right asset reference, so
anything that inspected the objects passed. It was simply never registered,
so no client could resolve or browse it.

The only thing that catches that is asking a client, over the wire, what
the address space actually exposes. This hosts the generator server through
the generic host, connects a real session, and asserts that discovery
returns the aggregation at /Powerhouse/Generators, that it carries an
enabled component binding naming generator.usda, and that every configured
set still publishes its own twin - the sibling failure, where the
aggregation is registered but the per-set bindings are lost, would render
geometry that never moves.

Faults are disabled for the fixture: the rotation exists for someone
watching a viewport, and a set tripping mid-run would make the assertions
depend on timing.

870 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
@marcschier
marcschier marked this pull request as draft August 4, 2026 15:58
marcschier and others added 6 commits August 4, 2026 18:11
The twin geometry was a stand-in: a box for the engine, a drum for the
alternator, a flat plate for the radiator. It scaled and coloured correctly
but it did not read as a generating set, and that matters more here than it
looks - a viewport is the only place some of this sample's behaviour is
legible at all, and a fan that turns or a radiator that changes colour is
hard to interpret on a shape that is not recognisably either.

Rebuilt from photographs of an open-frame V16 set. The skid is now channel
section - web plus flanges, cross members, lifting lugs - carrying the base
fuel tank, because the rails are what a real set is craned by and what gives
it its outline from any angle. The engine is a 60-degree V16 with crankcase,
sump, gear case and flywheel housing, two tilted heads carrying eight rocker
covers each, a manifold log per bank with eight risers and an elbow into its
turbocharger, charge-air pipes arcing over into the aftercooler in the vee,
and the service-side bank of spin-on filters. The alternator gained its
ventilation slots, end bells, terminal box and feet; the radiator a fin
pack, bolted guard uprights, header tanks and a shroud; the control panel a
fascia, display, mimic plate, keypad, emergency-stop mushroom and label
strip.

Colour follows the machine: body teal-green with a darker shade for shadowed
castings, scaled copper-oxide manifolds, bright steel, cream filter cans.

Prim names are load-bearing - the bindings drive Radiator/Fan,
Radiator/Core, Exhaust/Stack, the gauge needles, ControlPanel/RunLamp,
FuelTank/Surface, AlarmRing and the engine halos by path - so every one of
those survives the rework unchanged, which the asset contract tests assert
along with the matrix-transform and displayColor rules. The script's cube
and cylinder helpers gained optional rotation, which is what the V banks
need.

24 KB of geometry became 91 KB. 870 tests pass. Checked live against a
four-set server in the viewport, and re-scanned for vendor names.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Three things were wrong once the detailed geometry landed, all of which made
a running machine look like a still photograph.

The cooling fan was integrated at true engine speed. A fan on a 1500 rpm
engine sweeps 9000 degrees per second, so at the tick interval the published
angle advanced by several whole revolutions per sample and landed on a
near-arbitrary value each time - it strobed or sat still, and never read as
rotation. It is now scaled to a display rate whose per-tick step stays well
under the blade pitch. This is the one signal on the machine whose only job
is to say "this is turning", and it was not saying it.

Both fault halos were buried inside solid geometry. They sat at the engine
centre, which was fine against the old plain block and is invisible now that
there is a crankcase, a vee full of aftercooler and a sump in the way. An
indicator that cannot be seen is worse than no indicator, because it reads
as "no fault". They now sit clear of the machine, above the engine and
outboard of the sump.

The alternator had nothing that moved or changed at all, and the parts of a
real engine that visibly glow were not driven. Five bindings added: both
exhaust manifolds glow from the same exhaust temperature that drives the
stack, the alternator carries a heat band driven by load, and both
turbochargers turn with the fan's display angle. The manifolds and stack
share one source Variable rather than three, so a client asking why they are
glowing reads the single value behind all of them, and the turbos share the
fan's integrator rather than drifting away from it on their own.

Verified live: fan, both needles and both turbos all advancing between
samples, and every heat surface driving. 870 tests pass on net10.0 and
net48, 789 on net472; 0 warnings across every TFM.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The machine rendered entirely grey in the viewport. Its colours live in
UsdPreviewSurface networks, which only renderers that evaluate material
networks honour; the ones that do not fall back to primvars:displayColor,
and almost nothing declared it. The result was that none of the material
work was visible at all - a grey silhouette with a few red parts, which
were the only prims that happened to carry an explicit displayColor for
the heat bindings.

Every material now registers its diffuse colour, and cube, cylinder,
sphere and the fan blades emit it as displayColor alongside the material
binding. Prims a live binding recolours keep their explicit value and are
unaffected. 174 prims now carry colour, and the set renders in its body
green with copper manifolds, cream filter cans and yellow lifting lugs.

Verified in the viewport, and the asset contract tests still pass - the
displayColor rule they assert for Core and Stack is a strict subset of
what every prim now gets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
Two changes, both about the same problem: the sample's fault path was
working and invisible.

The fault schedule drove the *last* registered set. With the hero camera
framing the near end of the row, every trip happened off the far end of
the plant, so a viewer showed a healthy machine for the whole cycle and
the alarm path looked dead. It now drives the first set registered.

AlarmRing was a decal on the floor at z=0.02. From any operator-height
camera the skid and the neighbouring machines hide it completely, which
makes the one indicator that says "this machine has tripped" the hardest
thing in the scene to see. It is now a beacon ring above the set, at the
one height nothing else occupies.

Also documents in the README that colour bindings are renderer-dependent
- primvars:displayColor resolves as color3f[] from the UsdGeomGprim schema
whatever the layer declares, so a client that cannot write that exact type
cannot animate it - while the visibility bindings carry no such caveat and
are the reliable way to see state change in a viewport.

Fixes three doc comments that still described the fault as belonging to
the last set, and one that named a --no-faults switch that does not exist
(it is --faults false).

870 OpenUSD tests green on net10.0 and net48, 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The generator README and docs/OpenUsd.md both describe what the viewport
can and cannot show, but described it as a fact of life. It is not - each
one is a tracked gap in the OpenUsd packages, and a reader who hits it
should be able to find out whether it has been fixed rather than assuming
it is permanent.

Adds a "Known viewport limitations" section to docs/OpenUsd.md covering
the three that shape a live twin, each linked to its issue:

- colour cannot be animated, because primvars:displayColor resolves as
  color3f[] from the UsdGeomGprim schema and the managed API has no
  writer for that type (openusd-dotnet#2)
- bound UsdPreviewSurface materials are not shaded, so geometry renders
  untinted unless it also carries an explicit displayColor primvar
  (openusd-dotnet#4)
- stage-authored cameras are not opened automatically, so the opening
  shot is framed from stage bounds and shifts when geometry does
  (openusd-dotnet#3)

and points readers at visibility bindings, which carry none of them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The nodeset says it plainly - "AddIn that binds a domain Object to a
canonical composed USD prim path on a specific stage. Mounted with
HasAddIn" - but every server in the tree mounted it with plain
HasComponent.

This is an easy one to get wrong and a hard one to notice. HasAddIn is a
subtype of HasComponent, so a representation mounted with the wrong
reference type still browses, still aggregates, and still drives a twin
end to end. Every functional test passed before this change and passes
after it. The only thing that could tell the difference was a
conformance checker, and nothing in the repo asserted the reference
type on a sample server.

Seven mount sites, all now HasAddIn:

- OpenUsdRepresentationAuthoring.CreateRepresentation, the shared helper.
  GeneratorServer and SiteCompositionServer go through it, so they were
  fixed by fixing it.
- PumpDeviceIntegrationServer: the pump AddIn plus the three in
  OpenUsdComposition (production line, dynamically added line pump, and
  the represented-component factory).
- MinimalRobotServer: AttachRepresentation and the gripper tool.

PDIS and MinimalRobotServer hand-rolled the mount instead of calling the
shared helper, which is how they drifted apart from it.

Client discovery is unaffected: the registry path is Organizes, and both
browse helpers in OpenUsdConnector ask for HierarchicalReferences with
includeSubtypes, which HasAddIn satisfies through HasComponent.

Adds a regression test to each sample's E2E suite that browses inverse
HasAddIn from the representation and asserts exactly one owner. Both were
verified to fail when the corresponding fix is reverted, so they pin the
behaviour rather than merely accompanying it. The robot one checks all
15 representations, not just the first.

FakeSessionHarness did not model HasAddIn at all, so its subtype walk
returned false for it and the mock address space would have silently
stopped resolving AddIn-mounted representations. Adds
HasAddIn -> HasComponent to the map and mounts the mock representation
with HasAddIn, so the discovery test now exercises the real shape.

Documents the rule and the helper in docs/OpenUsd.md, which previously
covered asset delivery but not representation authoring.

870 OpenUSD tests and 404 DI tests green on net10.0 and net48, 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
@marcschier
marcschier marked this pull request as ready for review August 6, 2026 16:36
marcschier and others added 2 commits August 6, 2026 19:00
…sition

Two conflicts, plus one silent regression the merge would otherwise have
introduced.

UA.slnx - both sides appended sample projects at the same point. Kept
both: the WotCon trio from upstream and GeneratorServer /
SiteCompositionServer from this branch.

samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs - upstream
(#4138) deleted the ProductionLine's OpenUsdRepresentation outright, and
this branch had edited a line inside that same block. Took upstream's
deletion. The block existed at the merge base and upstream removed all
ten of its usages deliberately, with the reason stated in the code: the
aggregation is an address-space demo only, and rendering a line pump -
"a static topology entry, not a machine anyone is driving" - put phantom
pumps in the twin that no client could account for. This branch's only
change there was HasComponent -> HasAddIn, which is moot once the block
is gone.

The regression: upstream's new MaterialisePlantAggregationAsync adds a
plant-level representation, and it arrived mounted with HasComponent.
Git merged it cleanly because neither side touched those lines, so the
merge would have silently reinstated exactly the spec violation this
branch had just fixed everywhere else. Applied HasAddIn to it.

That also exposed a hole in the regression test this branch added:
it checked the Pump #1 representation alone, so it would have waved the
new plant representation straight through. Broadened it to check every
discovered representation, matching the robot suite. Verified it now
fails when plantRep is reverted to HasComponent, and that the old narrow
form did not.

Full UA.slnx build succeeds. 870 OpenUSD and 418 DI tests green on
net10.0. The 2 remaining CA1861 warnings are pre-existing on master from
#4117 and are untouched by this branch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
The coverage gate failed on patch coverage: 17 of 18 changed lines
uncovered, 5.56% against a 75% floor. Every one of them was in
ComposeCrossServerAsync - the §5.14 federation path that opens a session
to a subordinate server and drives its bindings into the same stage.

Nothing exercised it. The only server that declared a cross-server
component binding was the pump sample's ProductionLine, and master
removed that representation entirely, so the branch shipped a federation
feature with no server-side fixture to test it against.

Adds SiteFederationE2eTests, which runs two real servers: a
PumpDeviceIntegrationServer and a SiteCompositionServer configured to
point at it. SiteCompositionServer exists for exactly this - it owns no
devices and publishes one cross-server component binding per subordinate -
so the fixture is the feature's own intended topology rather than a
contrivance built to reach the lines.

Six tests, covering all 17:

- federation opens one session per cross-server component and the
  subordinate's machines land in the site's sink
- an unreachable subordinate leaves the rest of the scene standing: the
  placeholder prim is still composed and StartAsync still succeeds
- a remote connector that fails to construct closes the session that was
  already opened - the window where the session is owned by nobody, and
  the reason the inner try/catch exists. Asserted by observing CloseAsync
  on a mocked ISession, so it fails if that handler is removed
- cancellation propagates rather than being swallowed by the broad catch
- without a session factory the site renders its shell only, since
  federation is opt-in
- the site representation really publishes its subordinate's endpoint

Verified by collecting coverage over the new fixture alone: all 17 lines
the gate named are hit.

Di.Tests now references SiteCompositionServer on the same terms as the
other sample servers, and the new file joins the net472 Compile Remove
list beside PumpOpenUsdE2eTests because those references are excluded
there.

424 DI tests green on net10.0 and net48; net472 builds clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.50% (206304/238505 lines) >= 70.00%
✅ Project branch rate 76.25% >= 60.00%
ℹ️ Patch coverage no coverable changed lines -
ℹ️ Baseline delta (advisory) +12.90 pp 73.60% recorded

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 86.03%, branch 75.52%.

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.56%. Comparing base (8a10139) to head (f3d0d5b).
⚠️ Report is 22 commits behind head on master.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4180      +/-   ##
==========================================
+ Coverage   80.23%   80.56%   +0.32%     
==========================================
  Files        1515     1719     +204     
  Lines      209980   238505   +28525     
  Branches    36213    41276    +5063     
==========================================
+ Hits       168479   192140   +23661     
- Misses      28867    32253    +3386     
- Partials    12634    14112    +1478     
Flag Coverage Δ
actions 80.56% <100.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
....Ua.OpenUsd.Client/OpenUsdConnector.Composition.cs 87.80% <100.00%> (ø)
...a.OpenUsd.Server/OpenUsdRepresentationAuthoring.cs 100.00% <100.00%> (ø)

... and 365 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants