Add a Generators companion-spec sample and compose servers at a site level - #4180
Add a Generators companion-spec sample and compose servers at a site level#4180marcschier wants to merge 22 commits into
Conversation
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
There was a problem hiding this comment.
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
GeneratorDeviceIntegrationServersample (Generators model, datasheet-driven simulation, OpenUSD twin) plus a newOpc.Ua.Generators.Testssuite. - Added
SiteCompositionServersample that publishes a site shell and cross-server component bindings for subordinate servers. - Wired up
--federateinOpc.Ua.OpenUsd.Connectorand made cross-server federation best-effort per component inOpenUsdConnector.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.
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
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
…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
Code coverage✅ Coverage gate passed.
Coverage is above the recorded baseline - consider ratcheting Thresholds live in |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Description
Adds a Generators companion-specification sample alongside the existing pump sample, and a
SiteCompositionServerthat 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.
GeneratorServerA 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:
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·PFandη = P/(V̇·ρ·LHV)hold at every tick by construction. The publishedDATASHEET.md, the engineering ranges, the trip points and the simulation all read the same constants inGeneratorDatasheet.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
RemoteSessionFactoryalready existed in the client library but the connector CLI never set it, so cross-server composition was unreachable.--federatewires 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.
SiteCompositionServerOwns no devices. It publishes a site stage plus one cross-server component binding per subordinate, carrying that server's
ComponentServerUriandComponentEndpointUrl. 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:
IsShutdownandSubsystemNameare optional onGeneratorProtectionAlarmType, so the generated factory does not materialise them.CreateOrReplacealone produces a child that exists, appears inGetChildrenand holds the right value — but carries noReferenceTypeId, 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 itsHasPropertyreference.ResetFaultsand the wholeFaultbranch 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.SynchronizingandParalleledwere 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.Cooldownand a concurrentEmergencyStopcould interleave the pairedCurrentState/CurrentState.Idwrites 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.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);
ResetFaultsreports 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; andprepare_machinery_nodeset.pynow 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.
GeneratorStateMachineTypedeclares an emergency stop only out ofRunning,LoadedandParalleled, so the sample refusesEmergencyStopfromStartingandWarmup. 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 optionalStacklightmember 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
DeviceSetand 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 wroteprimvars:displayColorto 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/Plantand/Powerhouserather than leaving them stacked on the origin.OpenUsdRepresentationis now mounted withHasAddIn, notHasComponent(review feedback). The nodeset says so explicitly — "Mounted with HasAddIn" — but every server in the tree used plainHasComponent. It survived becauseHasAddInis a subtype ofHasComponent: 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.PumpDeviceIntegrationServerandMinimalRobotServerhad hand-rolled the mount instead of calling the sharedCreateRepresentationhelper, 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 carryingHasComponent.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:displayColorresolves ascolor3f[]from theUsdGeomGprimschema whatever the layer declares, and theOpenUsd 0.4.0-alphamanaged API has no writer for that type. BoundUsdPreviewSurfacematerials are not shaded either, so geometry renders grey unless it also carries an explicitdisplayColorprimvar. 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 fromdocs/OpenUsd.mdso 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 inOpenUsdConnector.Composition.cs). Happy to open one if maintainers would prefer it tracked.Checklist
Testing
The generator fixtures live in
tests/Opc.Ua.OpenUsd.Tests/Generator— 82 tests, no new project.Opc.Ua.Di.Testswould have been the natural home next to the pump fixtures, but both device samples source-generate their own reducedOpc.Ua.Machinerymodel and expose it viaInternalsVisibleTo, so referencing both from one assembly makes every Machinery type ambiguous (CS0433);Opc.Ua.OpenUsd.Testsreferences no other sample and already hostsRobotAssetContractTests. 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:
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 readsDATASHEET.mdand checks the document quotes the figures the server actually serves.IsLegalTransitionand the model'sGeneratorStateMap— 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.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 → Loadedpath, 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,ParalleledandFaultall observed on a two-set server, withHighCoolantTemperatureAlarmseen active on the fault subject. Method behaviour verified separately — per-set distinctCurrentStatenodes,EmergencyStop→EmergencyStoppedwithStartthen refused asBadInvalidState,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.