mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-26 04:19:53 +08:00
> Tracking issue #4576. #4577 is merged, so this branch is rebased onto > `master` and contains only the Machinery commits. ## Summary Second of four stacked PRs. Adds the OPC UA for Machinery series as model packages plus a server and a client SDK, following the companion pattern of DI and Robotics, and the two models it depends on that the repository does not carry yet. | Package | Content | |---|---| | `Opc.Ua.IA` | OPC 10000-200 IA 1.01.4, **moved out of `Opc.Ua.Robotics`** (see below) | | `Opc.Ua.ECM` | OPC 34100 Energy Consumption Management 1.0.1 | | `Opc.Ua.PADIM` | OPC 30081 PA-DIM 1.02.0 with the IRDI dictionary | | `Opc.Ua.Machinery` | OPC 40001-1 Basic Building Blocks 1.04.1 | | `Opc.Ua.Machinery.ProcessValues` | OPC 40001-2 Process Values 1.00.0 | | `Opc.Ua.Machinery.Jobs` | OPC 40001-3 Job Management 1.0.1 | | `Opc.Ua.Machinery.Energy` | OPC 40001-4 Energy 1.00 | | `Opc.Ua.Machinery.Result` | OPC 40001-101 Result Transfer 1.01.0 | | `Opc.Ua.Machinery.Server` | `AddMachinery()` / `ConfigureMachinery(...)`, fluent machine builder, server-driven state machines, process values, job management over the shared ISA-95 V2 binder, energy, result management incl. `GenerateFileForRead` | | `Opc.Ua.Machinery.Client` | machine discovery, identification, state observation, process values, job and result access | OPC 40001-101 `ResultTransferType` redeclares `GenerateFileForRead` with its own arguments; the generator fix for such overrides, merged with #4577, is what makes it appear on the `ResultTransfer` instances. All NodeSets are byte-identical to `UA-Nodeset/latest`. Each model carries a pinned `Model/*.NodeIds.csv` the generator validates, next to the unmodified upstream table; `tools/nodesets/derive-identifier-table.py` derives the former from the latter. ## Changes to existing code - **IA moves out of Robotics.** Machinery needs IA as well (OPC 40001-1 types `Stacklight` with `BasicStacklightType`), and two assemblies generating the same model under the same prefix collide at any consumer referencing both. The NodeSet moves verbatim, so no generated type changes; the prefix stays `Opc.Ua.IA`. Source-compatible, but IA types now live in `Opc.Ua.IA.dll` rather than `Opc.Ua.Robotics.dll` — binary-breaking for consumers of the Robotics package. - **`samples/OpenUsd/GeneratorServer`** drops its hand-reduced Machinery NodeSet copy and the script that produced it (a workaround for the generator defect fixed by #4577) and references `Opc.Ua.Machinery` instead. - `README.md` and `docs/README.md` list the new models; the README also corrects the Industrial Automation entry (Part 200, not OPC 40001-1). ## Review follow-ups Separate commits after the docs/samples commit, so the reviewed state stays comparable. Each fix comes with a test that fails without it. - Copilot review: a transition is committed only while the state machine is still in its source state; optional Jobs/Result namespaces the server does not load are reported as absent, as documented; resources of registered machines (result transfer, job-management change pumps) are released with the node manager (`DisposeMachineryResourcesAsync` for a custom DI manager); `NodeId`/`LocalizedText` use their null sentinels instead of `Nullable<T>`. - Own review with the rubric in `.github/copilot-instructions.md`: a failed browse call propagates from the client instead of reading as "no children"; the predefined result metadata compares `CreationTime` against the `DateTimeUtc` sentinels (the `DateTime` conversion shifted `DateTime.MaxValue` by the host's UTC offset); process values below `Monitoring` are found; a second metering point named `Main` is rejected; the zero-point event is reported when the handler throws; smaller state-sharing and documentation corrections. ## Testing - `tests/Opc.Ua.Machinery.Tests`: 162 tests on net8 and net10 and with the netstandard2.1 libraries; the integration tests connect a real client session over `opc.tcp` to a hosted server. - With IA extracted: Robotics 636, OpenUsd 921, DI 433 and SourceGeneration 263 tests pass. - `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10 with SDK 10.0.401 and no warnings. - Changed-line coverage of this layer (`check-coverage.ps1`): 91.68 % (6059 of 6609 changed lines, against `master`). - Fork CI, full PR matrix incl. macOS, on the current master: green, all 184 jobs ([run 36582685653](https://github.com/biancode/UA-.NETStandard/actions/runs/36582685653)). CodeQL: green ([run 36582689589](https://github.com/biancode/UA-.NETStandard/actions/runs/36582689589)). - Beyond the in-repo tests the implementation has been validated end to end against a separate, non-public OPC UA client and server. ## Reviewing Commits are ordered so each builds: IA extraction → identifier-table tool → ECM/PA-DIM → Machinery models → server → client → tests → docs/samples → GeneratorServer sample → review follow-ups. The model commits are large only because of the NodeSets and identifier tables; the hand-written code starts at the server commit. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
341 lines
17 KiB
Markdown
341 lines
17 KiB
Markdown
# GeneratorServer
|
||
|
||
A self-contained OPC UA server implementing the **Generators companion
|
||
specification** (`http://opcfoundation.org/UA/Generators/`) end to end: N
|
||
simulated generating sets, a datasheet-driven simulation, DI and Machinery
|
||
integration, and a per-set OpenUSD twin.
|
||
|
||
It complements [`PumpDeviceIntegrationServer`](../../DI/PumpDeviceIntegrationServer),
|
||
and the two are composed into one scene by
|
||
[`SiteCompositionServer`](../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`](DATASHEET.md), and every one of them is a
|
||
constant in [`GeneratorDatasheet.cs`](GeneratorDatasheet.cs), so the document, the
|
||
address space and the simulation cannot drift apart.
|
||
|
||
## The model
|
||
|
||
```mermaid
|
||
graph TD
|
||
GS["GeneratorSet_n : GeneratorSetType<br/>(DI DeviceType)"]
|
||
GS --> ID["Identification<br/>(Machinery)"]
|
||
GS --> BB[MachineryBuildingBlocks]
|
||
GS --> ST["OperatingState<br/>GeneratorStateMachineType"]
|
||
GS --> ENG["Engine<br/>speed, oil, coolant, exhaust, fuel, hours"]
|
||
GS --> ALT[Alternator]
|
||
ALT --> L1[L1]
|
||
ALT --> L2[L2]
|
||
ALT --> L3[L3]
|
||
GS --> CTL[Controller]
|
||
GS --> FUEL[FuelSystem]
|
||
GS --> COOL[CoolingSystem]
|
||
GS --> LUBE[LubricationSystem]
|
||
GS --> BATT[StartingSystem]
|
||
```
|
||
|
||
`GeneratorSetType` derives from the DI `DeviceType`, so — unlike the pump sample,
|
||
whose `PumpType` derives from the Machinery `MachineType` — sets are created
|
||
through `CreateDeviceAsync<TDevice>` and get DI registration and topology wiring
|
||
for free.
|
||
|
||
Most telemetry in the specification is **optional**, so the type fits both a bare
|
||
air-cooled residential set and a fully instrumented industrial one. This sample
|
||
models the instrumented case and opts in explicitly, including the `L2` and `L3`
|
||
phases (optional because single-phase sets populate only `L1`).
|
||
|
||
## The simulation
|
||
|
||
Load fraction is the **only** independent variable. Everything else follows from
|
||
the curves in the datasheet, so the published values reconcile by construction
|
||
rather than by coincidence:
|
||
|
||
```
|
||
V̇_f(x) = 3.67 + 100·x [L/h]
|
||
η(x) = P(x) / (V̇_f(x) · ρ · LHV)
|
||
S = P / PF I = S / (√3 · V_LL) f = N · p / 120
|
||
```
|
||
|
||
Verified live against a running server at 87.17 % load:
|
||
|
||
| Published | Value | Check |
|
||
|---|---|---|
|
||
| Real power | 348.69 kW | `0.8717 × 400 kW` |
|
||
| Voltage / current / PF | 400 V / 629.11 A / 0.8 | `√3·V·I·PF = 348.69 kW` |
|
||
| Fuel rate | 90.84 L/h | `3.67 + 100×0.8717` |
|
||
| Efficiency | 38.9 % | `P / (V̇·ρ·LHV)` |
|
||
| Frequency | 49.97 Hz | `1498.95 min⁻¹ × 4 / 120`, with governor droop |
|
||
|
||
Each set carries its own state and a phase-shifted duty point, so no two sets in a
|
||
plant report the same numbers — a four-set run showed 81.9 / 66.3 / 55.1 / 56.6 %.
|
||
|
||
## Control: state machine, protections, methods
|
||
|
||
The three surfaces the specification defines for controlling a set are all wired,
|
||
and all three are driven from **one** decider — the simulation's operating state.
|
||
A state machine driven independently of the physics eventually reports a machine as
|
||
running while the simulation says it is stopped, and nothing at run time notices.
|
||
|
||
### `OperatingState`
|
||
|
||
`GeneratorStateMachineType` is mandatory on the type, so it already exists; the
|
||
sample attaches behaviour to it in lifecycle mode rather than defining states:
|
||
|
||
```
|
||
Off ──▶ Ready ──▶ Starting ──▶ Warmup ──▶ Running ──┬──▶ Loaded ──▶ Cooldown ──▶ Stopping ──▶ Off
|
||
│ └──▶ Synchronizing ──▶ Paralleled ──▶ Loaded
|
||
▼
|
||
Fault ──▶ Off any running state ──▶ Fault / EmergencyStopped ──▶ Off
|
||
```
|
||
|
||
Twelve states, twenty-two declared transitions. The first set energises a dead bus
|
||
and closes straight onto it; every other set has to match voltage, frequency and
|
||
phase to a bus that is already live, which is what puts `Synchronizing` and
|
||
`Paralleled` on the path. Without that they would be states the model declares,
|
||
the diagram draws and a client can never observe — so a test drives the simulation
|
||
and asserts every declared state is actually entered.
|
||
|
||
`CurrentState` carries the readable name **and** its `Id` property, because a
|
||
client that gets a name it cannot resolve to a state node is no better off than one
|
||
that got nothing.
|
||
|
||
The states and transition numbers live in [`GeneratorStateMap.cs`](GeneratorStateMap.cs),
|
||
apart from the node manager, because they are a statement about the model rather
|
||
than about how this server publishes it — and because a test then holds them
|
||
against `GeneratorSimulation.IsLegalTransition` in both directions.
|
||
|
||
### Protection alarms
|
||
|
||
Four `GeneratorProtectionAlarmType` instances per set — **one per protection
|
||
function**, not one instance whose `ProtectionFunction` changes. A set can trip on
|
||
low oil pressure and overspeed in the same moment and an operator needs to see
|
||
both, which is also how a real control panel annunciates.
|
||
|
||
| Alarm | Supervises | Trips | Class |
|
||
|---|---|---|---|
|
||
| `LowOilPressureAlarm` | `LubricationSystem/OilPressure` | < 1.7 bar | shutdown |
|
||
| `HighCoolantTemperatureAlarm` | `Engine/CoolantTemperature` | > 98 °C | shutdown |
|
||
| `OverspeedAlarm` | `Engine/Speed` | > 1725 min⁻¹ | shutdown |
|
||
| `OverloadAlarm` | `Alternator/LoadPercent` | > 110 % | warning |
|
||
|
||
`OffNormalAlarmType` takes *healthy* as normal, so each instance carries `InputNode`
|
||
pointing at the variable it actually supervises — otherwise a client can see that
|
||
something tripped but not what was being watched.
|
||
|
||
**A healthy set cannot trip.** The datasheet curves are bounded well inside every
|
||
trip point — that is what a datasheet means — so a plant running to spec would
|
||
leave all four alarms, the shutdown class, `ResetFaults` and the whole `Fault`
|
||
branch of the state machine as code that never runs. The first set therefore
|
||
develops a fault on a slow rotation (`CoolingFailure` → `OilPressureLoss` →
|
||
`Overload` → `GovernorFailure`), trips, annunciates, shuts down and recovers. It
|
||
is the first set rather than the last because that is the machine nearest the
|
||
hero camera in the 3D twin — an alarm nobody can see demonstrates nothing. A
|
||
fault deviates the *measurement* from the curve, which is what a real fault does,
|
||
so the datasheet identities keep holding for every healthy set. Pass
|
||
`--faults false` for a purely healthy plant.
|
||
|
||
> **The trap that cost the most here.** `ProtectionFunction` is *mandatory* on the
|
||
> type, so the generated factory materialises it. `IsShutdown` and `SubsystemName`
|
||
> are *optional* and it does not. Calling `CreateOrReplaceIsShutdown(...).Value = x`
|
||
> on its own **appears to work** — the child exists, it is returned by
|
||
> `GetChildren`, it holds the value — but it has no `ReferenceTypeId`, so there is
|
||
> no reference for a browse to follow and the property is simply absent from every
|
||
> client's view. Optional members must be opted into with `AddXxx(context)` first.
|
||
> This was found by reading the running server with a client, not by reading the
|
||
> code, because the code looks correct. `GeneratorProtectionAlarmNodeTests` now
|
||
> asserts that every published member carries a reference a client can follow.
|
||
|
||
A shutdown trip is applied **after** every protection has been evaluated, not
|
||
inside the loop. Stopping the set mid-loop makes its remaining conditions read
|
||
healthy, so a set that lost oil pressure and overspeed in the same moment would
|
||
annunciate only whichever came first in the table — exactly the case that
|
||
one-alarm-per-function exists to show.
|
||
|
||
**A shutdown-class alarm latches; a warning-class one follows its condition.** The
|
||
trip is what removes the condition — oil pressure and coolant temperature are only
|
||
supervised while the engine turns — so an alarm that simply tracked its input would
|
||
go active for a single tick and clear, leaving an operator with a stopped machine
|
||
and no indication of why. It stays annunciated until the set leaves the shutdown
|
||
state, whether through `ResetFaults` or the automatic recovery. This was found by
|
||
watching a running server, not by a test: every unit test that checked the
|
||
*condition* passed.
|
||
|
||
Both raising and clearing report an event. A client learns of condition state
|
||
changes only through events, so clearing the node silently — which `ResetFaults`
|
||
used to do — leaves an alarm-list client showing the condition as active and
|
||
retained until it happens to issue a `ConditionRefresh`.
|
||
|
||
Low oil pressure is **bypassed while cranking**. Oil pressure has not built during
|
||
a start, so supervising it there trips every set the moment it tries to run; a real
|
||
set bypasses the trip for exactly this reason. This was a live defect here before
|
||
the bypass was added, and a test now pins it.
|
||
|
||
### Methods
|
||
|
||
| Method | Effect | Refused when |
|
||
|---|---|---|
|
||
| `Start` | `Off` → `Ready` → `Starting`, or `Ready` → `Starting` | already running |
|
||
| `Stop` | → `Cooldown` | not running |
|
||
| `EmergencyStop` | → `EmergencyStopped`, breaker open | see below |
|
||
| `ResetFaults` | clears latched alarms; `Fault`/`EmergencyStopped` → `Off` | never |
|
||
| `StartTest` | starts the set and publishes `Test` mode | already running |
|
||
| `SetOperatingMode` | writes `OperatingMode` | mode is not declared |
|
||
|
||
A refused request answers **`BadInvalidState`** rather than quietly doing nothing:
|
||
a method that silently succeeds without acting is the worst of the three outcomes,
|
||
because a client cannot tell it from a real success. Legality is decided in exactly
|
||
one place — `GeneratorSimulation.RequestState` — so no caller can drive a machine
|
||
from `Off` straight to `Loaded` by picking the right method.
|
||
|
||
Resetting a healthy set is a **no-op success**. An operator pressing reset on a
|
||
running machine has not done anything wrong, and answering `Bad` would only train
|
||
them to ignore the result.
|
||
|
||
> **A note on the emergency stop.** The model declares an emergency stop only out
|
||
> of `Running`, `Loaded` and `Paralleled`, so this sample refuses it from `Starting`
|
||
> and `Warmup`. A real panel stops from anywhere. That is the specification's shape,
|
||
> not this sample's choice, and it is left visible — with a test that pins it —
|
||
> rather than papered over with an undeclared transition.
|
||
|
||
The method semantics live in [`GeneratorCommands.cs`](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 is what makes an idle machine legible: without it the
|
||
3D view shows *that* a set is not turning but not *why* — `Cooldown` and
|
||
`EmergencyStopped` look identical from the outside.
|
||
|
||
### The machine you see
|
||
|
||
The geometry is an open-frame ~400 kW V16 genset, modelled to be recognisable
|
||
rather than schematic:
|
||
|
||
- **Skid** — channel-section side rails (web plus top and bottom flanges), five
|
||
cross members and lifting lugs, carrying the base fuel tank
|
||
- **Engine** — 60° V16: crankcase, sump, gear case and flywheel housing, two
|
||
tilted heads with eight rocker covers each
|
||
- **Exhaust side** — a manifold log per bank with eight risers and an elbow into
|
||
each turbocharger, in scaled copper-oxide brown
|
||
- **Turbochargers** — turbine housing, cartridge, compressor housing and inlet
|
||
- **Charge air** — riser / crossover / drop pipes arcing over each bank into the
|
||
aftercooler in the vee
|
||
- **Service side** — a bank of spin-on fuel filters, oil filters, starter,
|
||
charging alternator, water pump and coolant hoses
|
||
- **Alternator** — drum with a ventilation slot band, both end bells, terminal box
|
||
and mounting feet
|
||
- **Radiator** — core plus a fin pack, bolted guard uprights, header tanks, filler
|
||
cap, fan shroud and a nine-blade fan
|
||
- **Control panel** — cabinet with a dark instrument fascia, display, mimic plate,
|
||
keypad, emergency-stop mushroom and warning label strip
|
||
|
||
Detail is worth the bytes here: a viewport is the only place some of this sample's
|
||
behaviour is legible at all, and a machine that reads as a box makes a fan that
|
||
turns and a radiator that changes colour hard to interpret.
|
||
|
||
### The authoring rule that matters
|
||
|
||
A prim a connector positions **must** declare `xformOp:transform` in its
|
||
`xformOpOrder`. A connector folds Translation, Rotation and Scale into a single
|
||
matrix op, and `xformOpOrder` is `uniform` — it cannot be rewritten from the
|
||
stronger layer the connector edits. Naming `xformOp:translate` there instead makes
|
||
USD discard every value **in silence**, and every set renders on the origin.
|
||
|
||
Indicator geometry defaults to `invisible`, the plant aggregation composes with
|
||
`Reference` (not `Instance`, which would make descendants a shared prototype that
|
||
cannot carry per-set colour or rotation), and it is not declared `dynamic` because
|
||
the set of machines is fixed at start-up.
|
||
|
||
The geometry is generated — edit
|
||
[`Assets/generate_generator_assets.py`](Assets/generate_generator_assets.py) and
|
||
re-run it; never hand-edit `generator.usda` or `Powerhouse.usda`.
|
||
|
||
Prim names in that script are load-bearing. `OpenUsdBindings.cs` drives
|
||
`Radiator/Fan`, `Radiator/Core`, `Exhaust/Stack`, the two gauge needles,
|
||
`ControlPanel/RunLamp`, `FuelTank/Surface`, `AlarmRing` and the two engine halos
|
||
**by path**, so renaming any of them silently unbinds it —
|
||
`GeneratorAssetContractTests` asserts every one still resolves.
|
||
|
||
## The model files
|
||
|
||
`Model/` vendors the Generators nodeset only. Machinery comes from the shared
|
||
[`Opc.Ua.Machinery`](../../../src/Opc.Ua.Machinery) package, and IA from
|
||
[`Opc.Ua.IA`](../../../src/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
|
||
|
||
- [`DATASHEET.md`](DATASHEET.md) — the product datasheet the server is aligned to
|
||
- [`Generators.md`](Generators.md) — the model, the simulation design and cross-server composition
|
||
- [OpenUSD bindings](../../../docs/OpenUsd.md)
|
||
- [Device integration](../../../docs/DeviceIntegration.md)
|
||
- [`SiteCompositionServer`](../SiteCompositionServer) — composes this server with the pump server
|