Files
UA-.NETStandard/samples/OpenUsd/GeneratorServer/README.md
5899354441 Clarify documentation, migration guidance, and SDK selection (#4823)
# Description

Remove stock phrasing and correct misleading or inconsistent technical
documentation. The initial two passes covered 367 Markdown files,
followed by an interactive review of 169 editorial flags. The U5
interpretation originally deferred in that review has since received the
upstream Mantis answer recorded by #4843; remaining server/spec
follow-ups stay explicit in the CTT log.

- Correct package responsibilities, migration steps, API names,
ownership, protocol descriptions and links. Keep bundled migration
guides synchronized. Preserve historical conclusions as historical
records with separate re-check notes.
- Provide at least two rendered TOC levels throughout `docs/`. Convert
appropriate architecture figures to Mermaid without changing code
examples, transcripts or historical quotations.
- Address the October 19 feedback with concrete setup/migration
instructions, corrected certificate paths and PubSub names, shorter
repeated caveats, consistent XML comments, and a full review of the
Concepts guide. Retain #4839's API corrections.
- Integrate #4841's runtime/performance guidance and review
incoming/recent prose. Preserve dated measurements rather than claiming
new benchmark runs; clarify allocation ratios, startup-only processor
settings, JIT warm-up and the benchmark commit's scope. Missing raw
measurement artifacts and repeat ranges remain tracked by #4842.
- Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log
conflict using the updated Mantis states and OPC answers, retaining
historical framing and re-checks for C28/C32/C43.
- Address the latest naming/index feedback in `adb5bf693`: rename the
guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the
provider and DataType-declaration contracts into the conversion guide
and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and
`samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di,
FluentApi, ISA95, Lds and PubSub; update solution entries, project
references, Docker/CI paths, test fixture paths and documentation links
with exact casing.

**WoT guide scope.** This branch retains the detailed conversion guide
that #4758 had replaced with an anchor stub. Conversion limits, verified
document sets, preservation rules and provider ownership now live
together in `WotNodeSetConversion.md`; binding/runtime guidance remains
in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger
prose was shortened, the oversized argument-table cell moved into its
section, and existing anchors preserved.

**SDK policy is a separate, explicit user-directed change.**
`global.json` sets a stable minimum of `10.0.400`, `rollForward:
latestMajor`, and `allowPrerelease: false`. Newer major SDKs are
permitted; CI's explicit SDK pins are unchanged. README, GettingStarted,
DeveloperGuide and Recycling instructions reflect the possible analyzer
differences. The image-contract regression fix accepts compatible stable
SDK images instead of requiring exact equality with the floor.

The latest four rename/index threads are resolved after publication.
Three older threads remain open: universal obsolete-removal timing,
unconditional nonce-strength wording, and a blanket Vision NativeAOT
claim. Existing implementation/evidence constraints are explained in
their inline replies. The two-level TOCs remain as requested.

## Validation

- `git diff --check` and staged diff checks: passed. The untouched
upstream AutoID NodeSet retains its provenance bytes, including two
existing license-header trailing spaces.
- `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled
migration documents and manifests synchronized.
- Documentation/path validation: **101** documents retain two rendered
TOC levels (**2,228** entries), with idempotent formatting. **52** new
relative links and their anchors passed exact-case checks. No tracked
references to the old guide/sample paths remain. Both relocated
provider-contract sections are preserved in the destination.
- `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects
and the existing FluentApi/Lds/PubSub projects. Updated solution and
pump ProjectReference paths also passed exact-case existence checks.
- Latest focused commands, run from the repository root:

```powershell
# Passed: 61 channel send-order and reconnect tests.
dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal

# Passed: 5 pump datasheet tests; builds the renamed pump sample.
dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal

# Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models.
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal

# Passed: build, zero warnings/errors.
dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal

# Passed: dependent sample-test project build, zero warnings/errors.
dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal
```

Earlier focused integration checks passed 2,620 tests across AutoID,
event queues/scheduling, source generation, MCP/package profiles and
WoT. Both reviewed Mermaid diagrams rendered locally without network
access. These checks are not a full solution run.

The full solution has **not** been rerun for this review. Earlier
`dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did
not pass: three AOT projects rejected VSTest and 20 Vision tests failed
during graphics initialization with `OutOfMemoryException`. Remaining
long-running tests were stopped at the user's request. A focused Vision
rerun passed 558 tests and reproduced those 20 failures. The earlier
`dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was
blocked by the former SDK pin and was not retried after the policy
change. The user approved proceeding with those limitations documented;
current remote CI is not claimed green.

## Related Issues

- Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and
diagnostic-tool documentation refresh. #4841 supplies documentation and
earlier results, not all newly requested measurements.
- #4758 explains the conversion guide's earlier consolidation; the
current provider contracts are now consolidated into
`WotNodeSetConversion.md`.
- #4839 supplies the reviewed API corrections; #4843 supplies the
updated CTT issue record.
- The original prose cleanup and SDK policy were user-requested without
a source issue. No new runtime architecture is introduced by these
documentation edits.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] 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.

---------

Co-authored-by: Roman Ettlinger <romanett98@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:10:55 +02:00

17 KiB
Raw Permalink Blame History

GeneratorServer

A self-contained OPC UA server demonstrating the sample-local draft Generators model (http://opcfoundation.org/UA/Generators/): N simulated generating sets, a datasheet-driven simulation, DI and Machinery integration, and a per-set OpenUSD twin.

It complements PumpDeviceIntegrationServer, and the two are composed into one scene by SiteCompositionServer.

dotnet run --project samples/OpenUsd/GeneratorServer -- \
    --host localhost --port 62543 --generators 4
Argument Default Meaning
--generators N 2 How many sets to simulate (1…100)
--faults true Let the first set develop faults on a slow rotation
--port 62543 Endpoint port
--host 0.0.0.0 Bind host

The machine

A fictitious SimGen Systems GenX-500 — 400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole, on a 12.5 L turbocharged inline-six. Full parameters, curves and trip points are in DATASHEET.md, and every one of them is a constant in GeneratorDatasheet.cs, so the document, the address space and the simulation cannot drift apart.

The model

graph TD
  GS["GeneratorSet_n : GeneratorSetType<br/>(DI DeviceType)"]
  GS --> ID["Identification<br/>(Machinery)"]
  GS --> BB[MachineryBuildingBlocks]
  GS --> ST["OperatingState<br/>GeneratorStateMachineType"]
  GS --> ENG["Engine<br/>speed, oil, coolant, exhaust, fuel, hours"]
  GS --> ALT[Alternator]
  ALT --> L1[L1]
  ALT --> L2[L2]
  ALT --> L3[L3]
  GS --> CTL[Controller]
  GS --> FUEL[FuelSystem]
  GS --> COOL[CoolingSystem]
  GS --> LUBE[LubricationSystem]
  GS --> BATT[StartingSystem]

GeneratorSetType derives from the DI DeviceType, so — unlike the pump sample, whose PumpType derives from the Machinery MachineType — sets are created through CreateDeviceAsync<TDevice> and get DI registration and topology wiring for free.

Most telemetry in the specification is optional, so the type fits both a bare air-cooled residential set and a fully instrumented industrial one. This sample models the instrumented case and opts in explicitly, including the L2 and L3 phases (optional because single-phase sets populate only L1).

The simulation

Load fraction is the only independent variable. Everything else follows from the curves in the datasheet, so the published values reconcile by construction rather than by coincidence:

V̇_f(x) = 3.67 + 100·x                 [L/h]
η(x)   = P(x) / (V̇_f(x) · ρ · LHV)
S      = P / PF        I = S / (√3 · V_LL)        f = N · p / 120

Verified live against a running server at 87.17 % load:

Published Value Check
Real power 348.69 kW 0.8717 × 400 kW
Voltage / current / PF 400 V / 629.11 A / 0.8 √3·V·I·PF = 348.69 kW
Fuel rate 90.84 L/h 3.67 + 100×0.8717
Efficiency 38.9 % P / (V̇·ρ·LHV)
Frequency 49.97 Hz 1498.95 min⁻¹ × 4 / 120, with governor droop

Each set carries its own state and a phase-shifted duty point, so no two sets in a plant report the same numbers — a four-set run showed 81.9 / 66.3 / 55.1 / 56.6 %.

Control: state machine, protections, methods

The three surfaces the specification defines for controlling a set are all wired, and all three are driven from one decider — the simulation's operating state. A state machine driven independently of the physics eventually reports a machine as running while the simulation says it is stopped, and nothing at run time notices.

OperatingState

GeneratorStateMachineType is mandatory on the type, so it already exists; the sample attaches behaviour to it in lifecycle mode rather than defining states:

flowchart LR
    off["Off"] --> ready["Ready"] --> starting["Starting"] --> warmup["Warmup"] --> running["Running"]
    running --> loaded["Loaded"] --> cooldown["Cooldown"] --> stopping["Stopping"] --> off
    running --> syncing["Synchronizing"] --> paralleled["Paralleled"] --> loaded
    starting --> fault["Fault"] --> off
    any["Any running state"] --> fault
    any --> emergency["EmergencyStopped"] --> off

Twelve states, twenty-two declared transitions. The first set energises a dead bus and closes straight onto it; every other set has to match voltage, frequency and phase to a bus that is already live, which is what puts Synchronizing and Paralleled on the path. Without that they would be states the model declares, the diagram draws and a client can never observe — so a test drives the simulation and asserts every declared state is actually entered.

CurrentState carries the readable name and its Id property, because a client that gets a name it cannot resolve to a state node is no better off than one that got nothing.

The states and transition numbers live in GeneratorStateMap.cs, apart from the node manager, because they are a statement about the model rather than about how this server publishes it — and because a test then holds them against GeneratorSimulation.IsLegalTransition in both directions.

Protection alarms

Four GeneratorProtectionAlarmType instances per set — one per protection function, not one instance whose ProtectionFunction changes. A set can trip on low oil pressure and overspeed in the same moment and an operator needs to see both, which is also how a real control panel annunciates.

Alarm Supervises Trips Class
LowOilPressureAlarm LubricationSystem/OilPressure < 1.7 bar shutdown
HighCoolantTemperatureAlarm Engine/CoolantTemperature > 98 °C shutdown
OverspeedAlarm Engine/Speed > 1725 min⁻¹ shutdown
OverloadAlarm Alternator/LoadPercent > 110 % warning

OffNormalAlarmType takes healthy as normal, so each instance carries InputNode pointing at the variable it actually supervises — otherwise a client can see that something tripped but not what was being watched.

A healthy set cannot trip. The datasheet curves are bounded well inside every trip point, so a plant running to spec would leave all four alarms, the shutdown class, ResetFaults and the whole Fault branch of the state machine as code that never runs. The first set therefore develops a fault on a slow rotation (CoolingFailure → OilPressureLoss → Overload → GovernorFailure), trips, annunciates, shuts down and recovers. The first set is nearest the hero camera in the 3D twin, where its alarms are visible. A fault deviates the measurement from the curve, so the datasheet identities keep holding for every healthy set. Pass --faults false for a purely healthy plant.

The trap that cost the most here. ProtectionFunction is mandatory on the type, so the generated factory materialises it. IsShutdown and SubsystemName are optional and it does not. Calling CreateOrReplaceIsShutdown(...).Value = x on its own appears to work — the child exists, it is returned by GetChildren, it holds the value — but it has no ReferenceTypeId, so there is no reference for a browse to follow and the property is simply absent from every client's view. Optional members must be opted into with AddXxx(context) first. This was found by reading the running server with a client, not by reading the code, because the code looks correct. GeneratorProtectionAlarmNodeTests now asserts that every published member carries a reference a client can follow.

A shutdown trip is applied after every protection has been evaluated, not inside the loop. Stopping the set mid-loop makes its remaining conditions read healthy, so a set that lost oil pressure and overspeed in the same moment would annunciate only whichever came first in the table — exactly the case that one-alarm-per-function exists to show.

A shutdown-class alarm latches; a warning-class one follows its condition. The trip removes the condition — oil pressure and coolant temperature are only supervised while the engine turns — so an alarm that simply tracked its input would go active for a single tick and clear, leaving an operator with a stopped machine and no indication of why. It stays annunciated until the set leaves the shutdown state, whether through ResetFaults or the automatic recovery. This was found by watching a running server, not by a test: every unit test that checked the condition passed.

Both raising and clearing report an event. A client learns of condition state changes only through events, so clearing the node silently — which ResetFaults used to do — leaves an alarm-list client showing the condition as active and retained until it happens to issue a ConditionRefresh.

Low oil pressure is bypassed while cranking. Oil pressure has not built during a start, so supervising it there trips every set the moment it tries to run; a real set bypasses the trip for exactly this reason. This was a live defect here before the bypass was added, and a test now pins it.

Methods

Method Effect Refused when
Start Off → Ready → Starting, or Ready → Starting already running
Stop → Cooldown not running
EmergencyStop → EmergencyStopped, breaker open see below
ResetFaults clears latched alarms; Fault/EmergencyStopped → Off never
StartTest starts the set and publishes Test mode already running
SetOperatingMode writes OperatingMode mode is not declared

A refused request answers BadInvalidState rather than quietly doing nothing: a method that silently succeeds without acting is the worst of the three outcomes, because a client cannot tell it from a real success. Legality is decided in exactly one place — GeneratorSimulation.RequestState — so no caller can drive a machine from Off straight to Loaded by picking the right method.

Resetting a healthy set is a no-op success. An operator pressing reset on a running machine has not done anything wrong, and answering Bad would only train them to ignore the result.

A note on the emergency stop. The model declares an emergency stop only out of Running, Loaded and Paralleled, so this sample refuses it from Starting and Warmup. A real panel stops from anywhere. That is the specification's shape, not this sample's choice, and it is left visible — with a test that pins it — rather than papered over with an undeclared transition.

The method semantics live in GeneratorCommands.cs, expressed against the simulation rather than against address-space nodes, so they can be held to account by a test without standing up a server.

The OpenUSD twin

Each set owns a GeneratorTwin with its own prim path, signal Variables and bindings, so --generators N renders N machines that move independently. Sets lay out along +Y at 6 m pitch.

Sixteen live bindings drive the bay position, radiator fan, load and coolant gauge needles, exhaust and radiator colour, fuel-tank surface, a red alarm ring, overheat and low-oil halos at the subsystem each fault belongs to, a run lamp, and frequency / power / engine-hours / load / operating-state readouts.

Five more make the machine visibly alive rather than merely correct: both exhaust manifolds glow with exhaust temperature — those are the parts of a running engine that actually glow — the alternator carries a heat band driven by load, and both turbochargers turn. The manifolds and the stack share one source Variable, so a client asking why they are glowing reads the single value behind all three.

The fan is shown slower than it turns, deliberately. A fan on a 1500 rpm engine sweeps 9000°/s. Sampled at the tick interval that is several revolutions per sample, so the published angle jumps by a near-arbitrary amount and the blades either strobe or sit still — neither of which says "this machine is running", which is the only thing the signal exists to say. The display rate is scaled so the per-tick step stays well under the blade pitch.

Fault indicators sit clear of the machine. Both halos were originally tucked into the engine centre, which was fine against a plain block and invisible once there was a crankcase, an aftercooler and a sump in the way — and an indicator you cannot see is worse than none, because it reads as no fault. AlarmRing moved for the same reason: as a decal on the floor it was hidden by the skid and by the neighbouring machines from any operator-height camera, so it is now a beacon ring above the set, at the one height nothing else occupies.

Colour and visibility changes are both live viewport signals. Thermal bindings publish primvars:displayColor (color3f[]) for the radiator, exhaust, manifold glow and alternator heat band, and current OpenUSD packages animate those authored colours in the shipped viewer. The visibility bindings — run lamp, alarm beacon, fault halos — remain useful for binary state that must be unmistakable at a glance.

The operating-state readout explains why a machine is idle: without it the 3D view shows that a set is not turning but not why — Cooldown and EmergencyStopped look identical from the outside.

The machine you see

The illustrative geometry is an open-frame ~400 kW V16 genset. Its engine layout differs from the inline-six simulation datasheet; it is not a dimensionally or mechanically faithful model of that machine:

  • Skid — channel-section side rails (web plus top and bottom flanges), five cross members and lifting lugs, carrying the base fuel tank
  • Engine — 60° V16: crankcase, sump, gear case and flywheel housing, two tilted heads with eight rocker covers each
  • Exhaust side — a manifold log per bank with eight risers and an elbow into each turbocharger, in scaled copper-oxide brown
  • Turbochargers — turbine housing, cartridge, compressor housing and inlet
  • Charge air — riser / crossover / drop pipes arcing over each bank into the aftercooler in the vee
  • Service side — a bank of spin-on fuel filters, oil filters, starter, charging alternator, water pump and coolant hoses
  • Alternator — drum with a ventilation slot band, both end bells, terminal box and mounting feet
  • Radiator — core plus a fin pack, bolted guard uprights, header tanks, filler cap, fan shroud and a nine-blade fan
  • Control panel — cabinet with a dark instrument fascia, display, mimic plate, keypad, emergency-stop mushroom and warning label strip

Detail is worth the bytes here: a viewport is the only place some of this sample's behaviour is legible at all, and a machine that reads as a box makes a fan that turns and a radiator that changes colour hard to interpret.

The authoring rule that matters

A prim a connector positions must declare xformOp:transform in its xformOpOrder. A connector folds Translation, Rotation and Scale into a single matrix op, and xformOpOrder is uniform — it cannot be rewritten from the stronger layer the connector edits. Naming xformOp:translate there instead makes USD discard every value in silence, and every set renders on the origin.

Indicator geometry defaults to invisible, the plant aggregation composes with Reference (not Instance, which would make descendants a shared prototype that cannot carry per-set colour or rotation), and it is not declared dynamic because the set of machines is fixed at start-up.

The geometry is generated — edit Assets/generate_generator_assets.py and re-run it; never hand-edit generator.usda or Powerhouse.usda.

Prim names in that script must remain stable. OpenUsdBindings.cs drives Radiator/Fan, Radiator/Core, Exhaust/Stack, the two gauge needles, ControlPanel/RunLamp, FuelTank/Surface, AlarmRing and the two engine halos by path, so renaming any of them silently unbinds it — GeneratorAssetContractTests asserts every one still resolves.

The model files

Model/ vendors the Generators nodeset only. Machinery comes from the shared Opc.Ua.Machinery package, and IA from Opc.Ua.IA.

The sample used to vendor a reduced Machinery nodeset derived by a whitelist script, because the full official one did not survive the model source generator. That defect is fixed, so the reduction and the script are gone and the sample sees the same OPC 40001-1 1.04.1 model as every other consumer — including the Stacklight member that reaches into IA, which the reduction had stripped. GeneratorNodeManager therefore registers the IA namespace and loads the IA model alongside Machinery.

Rendering it

dotnet run --project tools/Opc.Ua.OpenUsd.Connector -- \
    --server opc.tcp://localhost:62543/GeneratorServer \
    --insecure --view

See also