Files
Klaus Landsdorf 01d6b5d26c Machinery: the OPC 40001 companion SDK with Jobs, Result Transfer, Process Values and Energy (#4578)
> 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>
2026-09-29 19:59:34 +02:00

230 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`. They
come from the shared [`Opc.Ua.Machinery`](../../../src/Opc.Ua.Machinery)
package, referenced like any other model library; the Generators model resolves
them through that assembly's `[assembly: ModelDependencyAttribute]` payload.
This sample used to vendor a reduced Machinery nodeset instead, derived from the
official one by a whitelist script, because the full official nodeset did not
survive the model source generator — it failed with `MODELGEN003`. Two defects
in the cross-assembly dependency machinery caused that, and both are fixed; see
[Machinery.md](../../../docs/Machinery.md#generator-gaps-found-while-adding-these-models).
One consequence of dropping the reduction: it had also removed the IA
dependency, which the full model carries through the optional `Stacklight`
member of `MonitoringType/Status`. `GeneratorNodeManager` now registers the IA
namespace and loads the IA model alongside Machinery.
## 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)