mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
01d6b5d26c
> 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>
230 lines
11 KiB
Markdown
230 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`. 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)
|