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