# Description Add a generic, company-neutral recycling simulation sample and reusable plastics/rubber companion-model packages. One durable causal coordinator and ten separately launched OPC UA process views provide deterministic material balances, finite buffers, genealogy, utilities, quality holds/disposition, authenticated controls and crash recovery. Process views consume committed state instead of running independent simulations. Real companion-model equipment instances use DI/Machinery/AMB discovery, Machine Vision, Pumps/Scales, granulate drying, extrusion, filtration and pelletizing. Vendor/process extensions remain sample-local. Connectors and WoT conversion are out of scope. **External source-contract parity and complete deployment qualification are not claimed complete.** The sample and managed tests target **.NET 8, 9 and 10**. The consolidated [recycling guide](samples/Recycling/README.md) explains the actual composition, simulation, observation paths and exposed data; the application-directory README points to that guide. Typed client member extensions are emitted at build time through the default-false `ModelSourceGeneratorClientAccessorsOnly` opt-in. Extrusion uses it for all twelve parts instead of checked-in generated C# and a Python generator. The shared client runtime retains typed values, quality, timestamps and streaming behavior. ### Package layout Six packages contain fourteen models in two model-producing assemblies: | Specifications | Model | Typed client | Server runtime | | --- | --- | --- | --- | | OPC 40083 and OPC 40082-6 | `Opc.Ua.PlasticsRubber` | `Opc.Ua.PlasticsRubber.Client` | `Opc.Ua.PlasticsRubber.Server` | | OPC 40084 parts 1–12 | `Opc.Ua.Extrusion` | `Opc.Ua.Extrusion.Client` | `Opc.Ua.Extrusion.Server` | NuGet IDs use the `OPCFoundation.NetStandard.` prefix. All six packages are registered in `.azurepipelines/expected-packages.txt` and the generated preview-pack solution. Granulate-drying code lives in `GranulateDrying` folders in the PlasticsRubber projects; extrusion uses per-part folders. There are no standalone granulate-drying or per-part extrusion packages. Published model identities and CLR namespaces remain distinct and unchanged. The common 1.03 interface is independently authored on the explicitly MIT-licensed 1.02 implementation basis; the official 1.03 XML is not redistributed. The other imported models retain their individual MIT notices. All 42 pinned model, identifier and basis files retain their manifest-backed bytes, including their staged Git blobs. No formal companion certification is claimed; the disputed Filter profile is not advertised. ## Related Issues No tracking issue is currently linked. ## 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. - [x] 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. - [x] I have addressed **all** PR feedback received. Current-head CI and coverage remain pending; the full local `UA.slnx` test matrix is not claimed complete.
44 KiB
OPC UA for Pumps and Vacuum Pumps (OPC 40223)
The Opc.Ua.Pumps* packages implement
OPC 40223, the
companion specification for pumps, compressors and vacuum pumps, on top of
OPC 40001-1 Machinery and OPC 10000-100 Device Integration. A server
publishes pumps with a fluent builder; a client discovers them and reads
them back into typed snapshots. Both sides share one set of contracts.
Contents
- Library layout
- The model
- Where a pump lives
- Quick start — server
- Quick start — client
- Server hosting
- Builder reference
- Updating values at runtime
- Client reference
- Contracts reference
- Conformance matrix
- Samples
- Testing
- Troubleshooting
- Model provenance
- Generator gap this model uncovered
- See also
Library layout
| Package | Contents |
|---|---|
Opc.Ua.Pumps |
The source-generated OPC 40223 model (typed node states, ObjectTypeIds, BrowseNames, the 13 enumerations and 5 option sets, PhysicalAddressDataType, AddOpcUaPumps), PumpsModel, and the snapshot contracts both sides share |
Opc.Ua.Pumps.Server |
PumpsNodeManager, the shared PumpsRuntime, the fluent builders IPumpBuilder / IPumpGroupBuilder, and standalone or additive Machinery hosting |
Opc.Ua.Pumps.Client |
PumpsClient for discovery, nameplate, configuration, measurements, supervision, maintenance, ports and whole-pump snapshots, plus AddPumpsClient() |
The contracts (PumpNameplate, PumpValueSet, PumpSupervisionStatus,
PumpSnapshot, …) live in the model package on purpose. A server fills them
from its process and a client reads them back, and neither side references the
other.
All three packages target the stack's library frameworks and are marked
NativeAOT-compatible on net10.0.
The model
| Model URI | C# namespace | Namespaces member |
Model loader |
|---|---|---|---|
http://opcfoundation.org/UA/Pumps/ |
Opc.Ua.Pumps |
Pumps |
AddOpcUaPumps |
The NodeSet defines 51 object types, 19 data types and roughly 8,900
variables. OPC 40223 defines no methods of its own; the 408 methods in the
NodeSet all belong to the FileType instances of the documentation groups.
PumpType is the entry point. It derives directly from the OPC 10000-100 DI
TopologyElementType (not from DI ComponentType and not from any Machinery
type) and carries seven groups:
| Group | Browse-name namespace | Type | What it holds |
|---|---|---|---|
Identification |
DI | PumpIdentificationType |
The nameplate. Mandatory |
Configuration |
DI | ConfigurationGroupType |
Design (78 parameters), Implementation (33), SystemRequirements (37) |
Operational |
DI | OperationalGroupType |
Measurements (50), Signals (21), Control, PumpActuation, BypassActuation, ThrottleValveActuation, MultiPump |
Maintenance |
DI | MaintenanceGroupType |
GeneralMaintenance, PreventiveMaintenance, ConditionBasedMaintenance, BreakdownMaintenance |
Events |
Pumps | SupervisionType |
Seven supervision categories, about 150 boolean fault signals |
Ports |
Pumps | PortsGroupType |
Drive, inlet-connection and outlet-connection ports |
Documentation |
Pumps | DocumentationType |
22 document/link pairs |
Mind the namespace column. Identification, Configuration,
Maintenance and Operational are OPC 10000-100 §5.6 instance names in
the DI namespace, not nodes of the Pumps model, so the Pumps generator emits
no BrowseNames constants for them. They are in PumpsModel.DiGroups
instead. Qualifying one of these four with the Pumps namespace index is the
most common way to get a Pumps browse path wrong, and it fails silently: the
path simply does not resolve.
Children of a group are spread over namespaces too. The nameplate is the extreme case: eleven fields come from OPC 10000-100, four from OPC 40001-1 and eleven from OPC 40223. The builder and the client search the Pumps, DI, Machinery and core namespaces in that order, so callers pass bare browse names and never have to know which namespace a field lives in.
MultiPumpType, despite the name, is not a pump. It is a functional group
below Operational describing one pump's role inside a multi-pump set.
Where a pump lives
A PumpType is a Device Integration topology element that OPC 40001-1
clients also treat as a machine, so a conforming server publishes it twice:
- as a child of the DI
DeviceSet, and - through an
Organizesreference from the MachineryMachinesfolder.
PumpsRuntime, used by both the standalone and composed hosts, does both by default
(PumpsServerOptions.OrganizeIntoMachinesFolder), and
PumpsClient.EnumeratePumpsAsync looks in both places and de-duplicates.
Matching goes by type hierarchy, so a vendor subtype of PumpType is found
as well.
Quick start — server
using Opc.Ua;
using Opc.Ua.Pumps;
using Opc.Ua.Pumps.Server;
using Opc.Ua.Pumps.Server.Builders;
// Opc.Ua and Opc.Ua.Pumps both declare BrowseNames, and System declares Range.
using BrowseNames = Opc.Ua.Pumps.BrowseNames;
using Range = Opc.Ua.Range;
services.AddOpcUa()
.AddServer<StandardServer>(ConfigureServer)
.AddPumps()
.ConfigurePumps(async context =>
{
IPumpBuilder pump = await context.Manager.CreatePumpAsync(
new QualifiedName("Pump_1", context.Manager.InstanceNamespaceIndex),
context.CancellationToken);
pump.WithNameplate(new PumpNameplate
{
NodeId = NodeId.Null,
Manufacturer = new LocalizedText("Acme Pumps"),
SerialNumber = "SN-001",
Model = new LocalizedText("PumpX-2000"),
Location = "Plant 1 / Utility Skid",
CountryOfOrigin = "DE"
});
pump.Measurements
.SetAnalog(BrowseNames.DifferentialPressure, 350_000,
new EUInformation { DisplayName = new LocalizedText("Pa") },
new Range { Low = 0, High = 1_000_000 })
.SetAnalog(BrowseNames.MassFlow, 12.5);
pump.Signals.SetDiscrete(BrowseNames.PumpOperation, true);
pump.Supervision(BrowseNames.SupervisionProcessFluid)
.SetDiscrete(BrowseNames.Cavitation, false);
pump.MaintenanceCategory(BrowseNames.GeneralMaintenance)
.Set(BrowseNames.StateOfTheItem, Variant.From(StateOfTheItemEnum.OperatingState));
pump.AddPort(PumpPortKind.InletConnection, "Suction")
.Set(BrowseNames.Direction, Variant.From(PortDirectionEnum.In));
});
The pump publishes exactly what the configurator set: the nameplate fields,
two measurements with their unit and range, one signal, one supervision flag,
one maintenance state and one port. Everything else in the ~3,000-node
PumpType surface stays unmaterialised.
Quick start — client
using Opc.Ua.Pumps;
using Opc.Ua.Pumps.Client;
using BrowseNames = Opc.Ua.Pumps.BrowseNames;
PumpsClient pumps = session.Pumps(telemetry);
if (!pumps.IsSupported)
{
return; // the server does not publish OPC 40223
}
await foreach (PumpEntry entry in pumps.EnumeratePumpsAsync(ct))
{
PumpNameplate? nameplate = await pumps.ReadNameplateAsync(entry.NodeId, ct);
PumpValueSet measurements = await pumps.ReadMeasurementsAsync(entry.NodeId, cancellationToken: ct);
double? flow = measurements.GetDouble(BrowseNames.MassFlow);
EUInformation? unit = measurements[BrowseNames.MassFlow]?.EngineeringUnits;
PumpSupervisionStatus? supervision = await pumps.ReadSupervisionAsync(entry.NodeId, cancellationToken: ct);
foreach ((string group, PumpValue signal) in supervision?.ActiveSignals ?? [])
{
Console.WriteLine($"{nameplate?.SerialNumber}: {group}/{signal.Name}");
}
}
Or the whole pump in one pass:
PumpSnapshot snapshot = await pumps.ReadPumpAsync(entry.NodeId, cancellationToken: ct);
Server hosting
AddPumps and ConfigurePumps
| Call | Effect |
|---|---|
AddPumps(Action<PumpsServerOptions>? configure = null) |
Registers PumpsNodeManager through PumpsNodeManagerFactory, loads DI, IA, Machinery and Pumps, and claims the Device Integration address space |
ConfigurePumps(Func<IPumpsSetupContext, ValueTask>) / ConfigurePumps(Action<IPumpsSetupContext>) |
Runs once the address space is initialised. Several registrations run in registration order; an exception aborts server startup |
IPumpsSetupContext gives the configurator the initialised Manager, the
underlying DI context (DiContext), the hosting CancellationToken and
GetRequiredService<T>() for application services, e.g. the process
connection that supplies the values.
PumpsNodeManager derives from DiNodeManager, so
ConfigureDevicesFor<PumpsNodeManager>() works on it unchanged;
ConfigurePumps is the typed shortcut.
Options
PumpsServerOptions |
Default | Meaning |
|---|---|---|
OrganizeIntoMachinesFolder |
true |
Also reference every pump from the OPC 40001-1 Machines folder. Turning it off hides pumps from Machinery clients |
LoadIndustrialAutomationModel |
true |
Load OPC 10000-200 IA. Machinery types MonitoringType/Status/Stacklight with the IA BasicStacklightType, so without IA the Machinery model fails to load; there is rarely a reason to turn this off |
AdditionalNamespaceUris |
[] |
Extra model or vendor-extension namespaces registered by the owner |
Load order
The load order is fixed: DI, then IA, then Machinery, then Pumps. IA has to be in the address space before Machinery loads, and Pumps builds on both.
Creating pumps
PumpsNodeManager / PumpsRuntime member |
Meaning |
|---|---|
CreatePumpAsync(QualifiedName browseName, CancellationToken) |
Creates a pump below the DI DeviceSet. A browse name already taken there is rejected with BadBrowseNameDuplicated |
CreatePumpAsync(QualifiedName, NodeState? parent, CancellationToken) |
Same, below an explicit parent |
Pump(PumpState) / Pump(NodeId) |
A builder for a pump the manager already created (null for an unknown NodeId) |
Pumps |
Every pump created so far, in creation order |
NamespaceIndices |
The Pumps, DI and Machinery namespace indices, resolved once |
CreatePumpAsync does not use DiNodeManager.CreateDeviceAsync: that method
requires a ComponentType descendant, and OPC 40223 derives PumpType
directly from DI TopologyElementType, the base of ComponentType. Each pump gets its own instance NodeIds, is registered
before the builder is returned (groups added later are visible immediately),
and is marked SubscribeToEvents so clients can subscribe to it as an event
notifier.
Coexisting with a server that already owns DI
AddPumps claims the Device Integration address space, so it cannot be
combined with AddOpcUaDi or another DI-owning registration; the second
registration throws InvalidOperationException at startup. A server that
uses the stock Machinery owner instead calls AddPumpsToMachinery. Do not
combine AddMachinery and AddPumps.
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Machinery.Server;
using Opc.Ua.Pumps.Server;
using Opc.Ua.Server;
services.AddOpcUa()
.AddServer<StandardServer>(ConfigureServer)
.AddMachinery(options =>
{
options.Parts = MachineryParts.BuildingBlocks;
options.InstanceNamespaceUri = "urn:acme:plant:instances";
})
.AddPumpsToMachinery()
.ConfigurePumpsFor<MachineryNodeManager>(async (context, ct) =>
{
PumpsRuntime pumps = context.Runtime;
var pump = await pumps.CreatePumpAsync(
new QualifiedName("FeedPump", pumps.InstanceNamespaceIndex), ct);
pump.WithNameplate(new Opc.Ua.Pumps.PumpNameplate
{
NodeId = NodeId.Null,
Manufacturer = new LocalizedText("Acme"),
SerialNumber = "P-1",
ProductInstanceUri = "urn:acme:pump:P-1"
});
pump.Measurements.SetAnalog(Opc.Ua.Pumps.BrowseNames.MassFlow, 12.5);
});
AddMachinery must precede AddPumpsToMachinery, and its selected parts must
include BuildingBlocks. PumpsMachineryModelProvider contributes only Pumps
and any additional configured namespaces; the built-in Machinery provider
loads DI, IA and Machinery first. The Machinery owner controls these shared
dependencies, so LoadIndustrialAutomationModel affects standalone hosting only.
The server package references Opc.Ua.Machinery.Server, but standalone servers
continue to load the same four models, not every Machinery part.
ConfigurePumpsFor<TNodeManager> accepts
Func<IPumpsBuildContext, CancellationToken, ValueTask>. Its Runtime uses
the same builders as PumpsNodeManager.Runtime. Manager is the existing
DI/Machinery owner, and GetRequiredService<T>() resolves application services.
Successive configurators share one runtime and run in the Machinery configuration
pipeline before its builder is sealed. Optional groups remain lazy, including
groups added after startup, and their updates still notify subscribed clients.
Pumps and other companions can therefore share one owner, for example by also
calling AddScalesToMachinery.
Model namespaces identify types and browse names; all newly created instance
NodeIds use the Machinery owner's application namespace. Do not repeat that
instance URI in AdditionalNamespaceUris.
For AMB, add AddAssetManagement() and register a pump inside its
configurator through the actual owner:
await context.GetRequiredService<Opc.Ua.AMB.Server.Assets.IAssetManagement>()
.RegisterAssetAsync(
context.Manager.TopologyElement(pump.Pump).Node,
asset => asset.WithConfigurableAssetId("P-1"),
ct);
No second DI manager is created. AMB remains a sidecar and the asset keeps
its native PumpType.
Direct composition and lifetime
Without dependency injection, supply new PumpsMachineryModelProvider() alongside
new MachineryModelProvider(MachineryParts.BuildingBlocks) to the
MachineryNodeManager constructor. Providers are dependency-ordered even if
supplied in reverse order. After the address space has initialized:
PumpsRuntime pumps = owner.CreatePumpsRuntime();
var pump = await pumps.CreatePumpAsync(
new QualifiedName("FeedPump", pumps.InstanceNamespaceIndex), ct);
The first CreatePumpsRuntime(options) call selects the options; later calls
return that same runtime. A standalone PumpsNodeManager returns its existing
Runtime. The stock Machinery manager disposes its composed runtime on shutdown,
cancelling queued creations and awaiting the active creation. Keep builders only
for the owner's lifetime.
An application-owned Machinery-compatible DI manager may instead load the same
models itself, call CreatePumpsRuntime, and release registered resources through
DisposeMachineryResourcesAsync() from its asynchronous disposal path.
No inheritance change to the sealed stock managers is required.
The PumpDeviceIntegrationServer sample
follows the same pattern in its own DiNodeManager subclass, because the same
manager also owns OpenUSD. It still source-generates its own copies of the
Machinery and Pumps models, which other test suites use as fixtures; moving it
onto these packages is left to a follow-up.
Builder reference
Nothing is materialised until it is asked for
The full PumpType surface is roughly 3,000 nodes. A pump that reports a flow
rate and a cavitation flag should publish those two, not 3,000 mostly empty
variables that cost every client a browse and a read to find out there is
nothing there. Every group and every leaf is therefore created on first use.
Touching pump.Measurements creates Operational and Measurements;
SetAnalog(BrowseNames.MassFlow, …) then creates MassFlow and, if a unit or
range is passed, its EngineeringUnits and EURange properties.
Addressing children by browse name
Both the builder and the value sets address everything below a group by
browse name, using the generated Opc.Ua.Pumps.BrowseNames constants.
DesignType alone declares 78 optional variables and the model some 3,000
across its types, so a method or property per child would make an unusable
API and an unmaintainable one. Passing a generated constant keeps the call
compile-time checked all the same: a rename in the model breaks the build.
A name the group does not declare is rejected with an
ArgumentException, rather than silently creating a node. A server that
invents children is worse than one that omits them, because a client cannot
tell a vendor extension from a typo.
IPumpBuilder
| Member | Returns a builder for |
|---|---|
Identification, Configuration, Operational, Events, Maintenance, Documentation |
The seven top-level groups (ports via AddPort) |
Design, Implementation, SystemRequirements |
The three Configuration sub-groups |
Measurements, Signals, Control, PumpActuation, MultiPump |
Operational sub-groups. BypassActuation and ThrottleValveActuation via Operational.Nested(...) |
Supervision(string) |
One of the seven Events categories (BrowseNames.Supervision…) |
MaintenanceCategory(string) |
One of the four Maintenance categories (BrowseNames.…Maintenance) |
WithNameplate(PumpNameplate) |
Fills Identification from the shared contract; see below |
AddPort(PumpPortKind, string name) |
Creates a drive, inlet or outlet port below Ports and returns its builder |
With(Action<PumpState>) |
Escape hatch for anything else on the pump node |
Pump, NodeId |
The pump itself |
IPumpGroupBuilder
| Member | Effect |
|---|---|
Add(params string[] browseNames) |
Materialises children without setting values |
Set(string browseName, Variant value) |
Materialises a variable, sets its value, status Good and timestamp, and publishes the change |
SetAnalog(string, double, EUInformation?, Range?) |
As Set, and also publishes EngineeringUnits / EURange if the child declares them |
SetDiscrete(string, bool) |
Sets a boolean in either shape OPC 40223 uses (see below) |
Nested(string) |
Builder for a nested group (an object child) |
AddVibration(string name) |
Creates a named <Vibration> instance in a Measurements group; see below |
Child(string) |
The materialised child node, or null when the group declares no such child |
With(string, Action<BaseInstanceState>) |
Escape hatch: access levels, historizing, a method callback, OnReadValue |
Node |
The group node |
SetDiscrete covers both boolean shapes in the model:
- the
TwoStateDiscreteTypevariables of the supervision groups (Cavitation,Dry,MotorOverheat, …), which carry the state themselves; - the
DiscreteInputObjectType/DiscreteOutputObjectTypeobjects of the signal and actuation groups (PumpOperation, …), whose state sits one level down inDiscreteInputValueorDiscreteOutputValue.
The nameplate
WithNameplate publishes every field of PumpNameplate that is set and
leaves the rest unmaterialised. PumpNameplate.NodeId and MarkingsFolderId
are ignored on the server side; they are filled in by the client when reading.
| Fields | Namespace |
|---|---|
Manufacturer, ManufacturerUri, Model, ProductCode, HardwareRevision, SoftwareRevision, DeviceClass, SerialNumber, ProductInstanceUri, AssetId, ComponentName |
OPC 10000-100 DI |
Location, InitialOperationDate, YearOfConstruction, MonthOfConstruction |
OPC 40001-1 Machinery |
DayOfConstruction, ArticleNumber, OrderProductCode, TypeOfProduct, Supplier, CountryOfOrigin, FabricationNumber, GTINCode, NationalStockNumber, PhysicalAddress |
OPC 40223 Pumps |
Ports
PortsGroupType declares its ports as placeholders: which ports a pump has is
an instance property, not a type property. AddPort therefore creates the
instance from the port type and names it. The returned builder reaches the
port's own groups:
PumpPortKind |
Type | Groups |
|---|---|---|
Drive |
DrivePortType |
Design, Measurements |
InletConnection |
InletConnectionPortType |
Design, Implementation, Measurements, SystemRequirements |
OutletConnection |
OutletConnectionPortType |
Design, Implementation, Measurements, SystemRequirements |
pump.AddPort(PumpPortKind.OutletConnection, "Discharge")
.Set(BrowseNames.Direction, Variant.From(PortDirectionEnum.Out));
Vibration measurements and other placeholders
OPC 40223 §7.32 declares vibration measurements as the OptionalPlaceholder
<Vibration> of MeasurementsType, typed VibrationMeasurementType (§7.33,
35 analog values after IEC 61987 plus ReferenceStandardForVibrationMeasurement).
A pump publishes one instance per measuring point, each named by the
application, which is what AddVibration creates:
pump.Measurements.AddVibration("DriveEndBearing")
.SetAnalog(BrowseNames.OverallVibrationVelocityRMS, 2.8,
new EUInformation { DisplayName = new LocalizedText("mm/s") })
.Set(BrowseNames.ReferenceStandardForVibrationMeasurement, Variant.From("ISO 10816-3"));
Asking again for an existing name returns the same instance. A group without
a <Vibration> placeholder rejects the call.
A placeholder is a template, not a child: Nested("Vibration") fails with
"'Measurements' declares '' as a placeholder, not as a child -
create named instances with AddVibration(name)". The model has three more
placeholder kinds:
| Placeholder | Type | Builder API |
|---|---|---|
<Vibration> in MeasurementsType |
VibrationMeasurementType |
AddVibration(name) |
<Drive>, <InletConnection>, <OutletConnection> in PortsGroupType |
the three port types | AddPort(kind, name) |
<Marking> in MarkingsType |
FileType |
none; a marking is a file, see Documentation files |
Documentation files
The Documentation group pairs each document link with a FileType object.
The library publishes the links; it does not implement the FileType
methods (Open, Read, …). To serve the files, attach the handlers yourself
through With(browseName, node => …), or leave the file objects out and
publish only the links.
Updating values at runtime
The builder is also how an application keeps values current, e.g. from a polling loop against the pump controller:
IPumpBuilder pump = manager.Pump(pumpNodeId)!;
pump.Measurements.SetAnalog(BrowseNames.MassFlow, reading.MassFlow);
pump.Supervision(BrowseNames.SupervisionProcessFluid)
.SetDiscrete(BrowseNames.Cavitation, reading.Cavitation);
Every Set, SetAnalog and SetDiscrete stamps the value with status
Good and the current time and publishes the change. PumpsNodeManager
reports data changes by exception, so this publish is what makes a monitored
item fire; a client subscribed to MassFlow sees each update, and
PumpsClientServerE2eTests checks exactly that over a real subscription.
For a signal object the publish happens on the DiscreteInputValue /
DiscreteOutputValue child that carries the state, which is also the node a
client subscribes to (PumpValue.NodeId).
Keep the builders you need, as the
PumpsServer sample does, instead
of resolving them on every update. Values that are only ever read and never
subscribed to can instead be bound to their source once with
With(browseName, node => ((BaseVariableState)node).OnReadValue = …).
Client reference
Creating the client
| Entry point | Use |
|---|---|
session.Pumps(telemetry) |
Extension on ISession; registers the OPC 40223 structured types with the session's encodeable factory |
new PumpsClient(session, telemetry) |
The same without the extension |
AddPumpsClient() on AddClient(...) |
Registers PumpsClientFactory and a Func<CancellationToken, Task<PumpsClient>> over the managed session, plus the DI client services |
The registered types (PhysicalAddressDataType and the five option sets)
decode into the generated classes instead of staying opaque
ExtensionObjects.
Discovery
| Member | Meaning |
|---|---|
IsSupported |
The server publishes the Pumps namespace |
PumpTypeId, MachinesFolderId |
Resolved against the session's namespace table, NodeId.Null if absent |
EnumeratePumpsAsync() |
Every pump below the DI DeviceSet and the Machinery Machines folder, each returned once |
EnumeratePumpsUnderAsync(NodeId root) |
Pumps directly below one folder |
DiscoverPumpsAsync() |
The NodeIds of EnumeratePumpsAsync |
IsPumpAsync(NodeId typeDefinition) |
Whether a type is or derives from PumpType |
ResolveIdentificationAsync, ResolveDiGroupAsync, ResolvePumpsChildAsync, ResolveOperationalAsync, ResolvePortsAsync |
Resolve a group's NodeId for use with ReadValueSetAsync |
Topology exposes the underlying DiTopologyClient.
Reading groups
| Member | Returns |
|---|---|
ReadNameplateAsync(pump) |
PumpNameplate? |
ReadMarkingsAsync(markingsFolder) |
The entries of the nameplate's Markings folder (PumpNameplate.MarkingsFolderId) |
ReadConfigurationAsync(pump, options) |
PumpConfigurationData? with Design, Implementation, SystemRequirements |
ReadOperationalAsync(pump, options) |
PumpOperationalData? with all operational groups and MultiPump |
ReadMeasurementsAsync, ReadSignalsAsync |
One PumpValueSet |
ReadMultiPumpAsync(pump) |
MultiPumpConfiguration? |
ReadSupervisionAsync(pump, options) |
PumpSupervisionStatus? |
ReadMaintenanceAsync(pump, options) |
PumpMaintenanceData? |
ReadDocumentationAsync(pump) |
The document links as a PumpValueSet |
EnumeratePortsAsync, ReadPortsAsync |
PumpPortDescriptors with their groups |
ReadPumpAsync(pump, options) |
PumpSnapshot: everything above in one pass |
ReadValueSetAsync(group, options) |
Any group by NodeId |
A null result means the pump does not publish that group.
Value sets, not records
The large groups are open sets of optional variables, and a real pump
publishes a handful of them. A record with one property per variable would be
mostly null and would need revising with every revision of the
specification. ReadValueSetAsync browses the group instead and returns a
PumpValueSet filled from what the server actually publishes: three entries
for a pump that publishes three DesignType parameters, 78 for one that
publishes all of them.
-
Discrete signal objects are followed automatically, and their value is reported under the object's name (
PumpOperation, notDiscreteInputValue). -
Other nested OPC 40223 objects are groups of their own and come back in
PumpValueSet.Groups, one level deep: the<Vibration>instances belowMeasurements, for example.foreach ((string point, PumpValueSet vibration) in measurements.Groups) { double? velocity = vibration.GetDouble(BrowseNames.OverallVibrationVelocityRMS); }Objects of the base models, such as the
FileTypedocuments, are left out; they carry no values.
The closed parts of the specification are records: PumpNameplate (the
nameplate list is fixed by OPC 40223) and MultiPumpConfiguration, which
maps all eleven MultiPumpType variables (§7.34), including the string
arrays DistributionPriority, PumpCollectiveIDs and RedundantPumpIDs.
Read options
Each PumpValue carries the engineering unit and instrument range next to the
reading. An OPC 40223 measurement without its unit cannot be interpreted
(350 kPa and 350 Pa are different machines), and the specification fixes no
unit for its analog variables. That costs one extra round trip per group.
PumpReadOptions |
Default | |
|---|---|---|
IncludeEngineeringUnits |
true |
Read EngineeringUnits |
IncludeRanges |
true |
Read EURange |
IncludeTimestamps |
false |
Return source timestamps |
PumpReadOptions.ValuesOnly |
One browse and one read, no metadata |
Read once with the defaults and poll with ValuesOnly: the metadata does not
change.
Supervision
SupervisionType spreads about 150 boolean fault signals over seven groups.
What a caller usually wants is not one named flag but the answer to "what is
this pump complaining about":
PumpSupervisionStatus? supervision = await pumps.ReadSupervisionAsync(pumpId, cancellationToken: ct);
if (supervision?.HasActiveSignals == true)
{
foreach ((string group, PumpValue signal) in supervision.ActiveSignals)
{
Console.WriteLine($"{group}/{signal.Name}");
}
}
ActiveSignals yields only signals that are currently raised. A signal
published as false, or with a bad status, is left out; an empty result is
the healthy case.
Maintenance
PumpMaintenanceData exposes the four categories as value sets plus three
derived answers: StateOfTheItem and MaintenanceLevel (from
GeneralMaintenance) and HasFailure (from BreakdownMaintenance).
Contracts reference
| Type | Kind | Filled by |
|---|---|---|
PumpEntry |
Record: NodeId, browse name, display name, type definition | Discovery |
PumpNameplate |
Closed record | WithNameplate / ReadNameplateAsync |
PumpValue |
One reading with status, source timestamp, unit and range; AsDouble, AsBoolean, AsString, AsStringArray, AsUInt32, AsEnum<T>, AsDateTime |
Value sets |
PumpValueSet |
Browse-name-keyed readings; GetDouble, GetBoolean, GetString, GetStringArray, GetUInt32, GetDateTime, GetEnum<T>, indexer, Names, Contains; nested groups in Groups / Group(name) |
All open groups |
PumpConfigurationData, PumpOperationalData, PumpMaintenanceData |
Records of value sets | Group reads |
MultiPumpConfiguration |
Record of all eleven MultiPumpType variables |
ReadMultiPumpAsync |
PumpSupervisionStatus |
Seven value sets, ActiveSignals, HasActiveSignals |
ReadSupervisionAsync |
PumpPortDescriptor |
Port kind, direction, category, id carrier, four value sets | Port reads |
PumpSnapshot |
Everything above for one pump | ReadPumpAsync |
Absent names read as null, never as an exception, and a reading with a bad
status reads as no value. Integer readings convert to double in GetDouble.
Conformance matrix
Status key: ✅ implemented and tested over a real session · 🧪 implemented, tested in-process only · 🔧 implemented, not covered by a test · ❌ not shipped.
Profiles and conformance units
OPC 40223 1.0 defines ten server conformance units (§9.1, Table 152) and two server profiles (§9.2, Tables 153–155). It defines no client facets.
| Profile | URI | Status |
|---|---|---|
| Pump Base Server Profile (Table 154) | http://opcfoundation.org/UA-Profile/Pumps/Server/Base |
Partial: Base System and Pump PumpType Mandatory Nodes are met, but the mandatory 3:Machine Identification Writable Server Facet is not, because client writes to the identification are not stored |
| Pump Advanced Server Profile (Table 155) | http://opcfoundation.org/UA-Profile/Pumps/Server/Advanced |
❌ Not met: the mandatory alarm, notifier and historical-access facets are not implemented |
PumpsNodeManager therefore advertises neither profile URI.
| Conformance unit (Table 152) | Status | Where |
|---|---|---|
| Base System | ✅ | PumpType instances, see the server table below |
| Pump PumpType Mandatory Nodes | ✅ | CreatePumpAsync materialises every mandatory node |
| Pump Identification | ✅ | Identification nameplate |
| PumpClass Data | 🔧 | PumpClass in the Design group can be set through the group builder; nothing enforces it or tests it |
| Pump Connection Port | ✅ | Inlet and outlet connection ports |
| Pump Drive Port | ✅ | Drive port |
| Pump Control | 🧪 | PumpActuation; Control is not covered by a test |
| Historizing | ❌ | No historical access for measurements |
| Limit Alarm Status | ❌ | No LimitAlarmType alarms |
| Supervision Health Status | ❌ | No DeviceHealthDiagnosticAlarmType alarms |
The rows below follow the specification's structure.
Model
| Area | Status | Source | Tests |
|---|---|---|---|
Namespace URI, all 51 object types, 13 enumerations, 5 option sets, PhysicalAddressDataType generated |
✅ | Model/ |
PumpsModelTests |
DI group names outside the Pumps BrowseNames (PumpsModel.DiGroups) |
🧪 | PumpsModel |
PumpsModelTests |
Port type classification (PumpsModel.ClassifyPort) |
🧪 | same | PumpsModelTests |
| Model loading DI → IA → Machinery → Pumps | 🧪 | PumpsNodeManager |
PumpsNodeManagerTests |
Server
| Area | Status | Source | Tests |
|---|---|---|---|
PumpType instance below DeviceSet with its own instance NodeIds |
✅ | PumpsNodeManager |
PumpsNodeManagerTests; PumpsClientServerE2eTests |
Organized from the Machinery Machines folder, optional |
✅ | same | PumpsNodeManagerTests; PumpsClientServerE2eTests |
| Duplicate pump names rejected | 🧪 | same | PumpsNodeManagerTests |
| Pump is an event notifier | 🧪 | same | PumpsNodeManagerTests |
| Lazy materialisation of groups and leaves | 🧪 | PumpGroupBuilder |
PumpBuilderTests |
| Materialised children carry reference type, type definition, data type and access level | ✅ | same | PumpBuilderTests; PumpsClientServerE2eTests |
| Undeclared browse names rejected | 🧪 | same | PumpBuilderTests |
Identification nameplate across DI / Machinery / Pumps |
✅ | PumpBuilder |
PumpBuilderTests; PumpsClientServerE2eTests |
Configuration: Design, SystemRequirements |
✅ | PumpGroupBuilder |
PumpsClientServerE2eTests |
Configuration: Implementation |
🔧 | same | — (same code path) |
Operational: Measurements with unit and range |
✅ | same | PumpBuilderTests; PumpsClientServerE2eTests |
Operational: Signals (discrete input objects) |
✅ | same | PumpBuilderTests; PumpsClientServerE2eTests |
Operational: PumpActuation (discrete output objects) |
🧪 | same | PumpBuilderTests |
Operational: MultiPump, all eleven variables |
✅ | same | PumpsClientServerE2eTests |
Operational: Control, BypassActuation, ThrottleValveActuation |
🔧 | same | — |
Measurements: <Vibration> instances of VibrationMeasurementType |
✅ | PumpGroupBuilder |
PumpBuilderTests; PumpsClientServerE2eTests |
Events: seven supervision categories |
✅ | same | PumpBuilderTests; PumpsClientServerE2eTests |
Maintenance: four categories |
✅ | same | PumpBuilderTests; PumpsClientServerE2eTests |
Ports: drive, inlet and outlet ports with their groups |
✅ | PumpBuilder |
PumpBuilderTests; PumpsClientServerE2eTests |
Documentation links |
🔧 | builder | — |
Documentation FileType methods |
❌ | — | Optional (§7.5); attach through With(...) |
MarkingsType <Marking> files |
❌ | — | Optional; FileType, see Documentation files |
CU Supervision Health Status: NE 107 alarms (2:DeviceHealthDiagnosticAlarmType subtypes) for supervision signals |
❌ | — | Optional (Table 152/155). OPC 40223 defines no event types of its own; the pump is an event notifier, the alarms are not emitted |
CU Limit Alarm Status: 0:LimitAlarmType alarms for measurements |
❌ | — | Optional (Table 152/155) |
| Data changes to subscribed clients on runtime updates | ✅ | PumpGroupBuilder |
PumpBuilderTests; PumpsClientServerE2eTests |
AddPumps / ConfigurePumps hosting |
✅ | OpcUaPumpsServerBuilderExtensions |
PumpsClientServerE2eTests |
| Rejecting a second DI owner | 🔧 | same | — |
Client
| Area | Status | Source | Tests |
|---|---|---|---|
Discovery from DeviceSet and Machines, de-duplicated, subtypes included |
✅ | PumpsClient |
PumpsClientServerE2eTests |
| Nameplate | ✅ | PumpsClient.Identification |
PumpsClientServerE2eTests |
| Markings | 🔧 | same | — |
| Configuration, measurements, signals, supervision, maintenance | ✅ | PumpsClient.Groups |
PumpsClientServerE2eTests |
| Multi-pump configuration, all eleven variables | ✅ | same | PumpsClientServerE2eTests |
| Documentation links | 🔧 | same | — |
| Value sets with units, ranges and discrete objects | ✅ | PumpsClient.Values |
PumpValueSetTests; PumpsClientServerE2eTests |
Nested OPC 40223 objects (vibration measurements) as PumpValueSet.Groups |
✅ | same | PumpsClientServerE2eTests |
| Ports with kind and groups | ✅ | PumpsClient.Ports |
PumpsClientServerE2eTests |
| Whole-pump snapshot | ✅ | PumpsClient.Snapshot |
PumpsClientServerE2eTests |
Supervision evaluation (ActiveSignals) |
🧪 | PumpSupervisionStatus |
PumpValueSetTests |
AddPumpsClient hosting |
🔧 | OpcUaPumpsClientBuilderExtensions |
— |
Samples
| Sample | Shows |
|---|---|
| PumpsServer | AddPumps / ConfigurePumps with two pumps: nameplate, every group builder, units and ranges, ports, documentation links, and a simulation that publishes runtime updates to subscribers |
| PumpsClient | PumpsClient discovery, ReadPumpAsync snapshots, supervision and maintenance evaluation, ValuesOnly polling, and streaming subscriptions on PumpValue.NodeId |
| PumpDeviceIntegrationServer | A datasheet-driven pump simulation in its own DiNodeManager subclass with the Pumps model loaded next to OpenUSD, published data changes, and an OpenUSD twin per pump |
| WoT aggregation | A DI / Machinery / Pumps PumpType instance loaded at runtime from WoT documents, with no Pumps assembly referenced |
| SiteCompositionServer | A supervisory server composing the pump and generator servers into one scene |
Testing
dotnet test tests/Opc.Ua.Pumps.Tests
| Fixture | Covers |
|---|---|
PumpsModelTests |
The generated model against the specification |
PumpsNodeManagerTests |
Model loading, pump creation, folders, NodeIds |
PumpBuilderTests |
Every builder shape in-process |
PumpValueSetTests |
Value-set and supervision evaluation |
PumpsCompositionTests |
Stock Machinery owner, ordered providers, lazy groups, instance ownership, cancellation and runtime disposal |
PumpsClientServerE2eTests |
Standalone and composed Machinery hosts, read back by PumpsClient over real sessions and live data-change subscriptions |
The end-to-end test is the one that matters for anything a client sees. An in-process assertion on the typed model passes even when a client sees nothing, as the generator gap below shows.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
CS0104: 'BrowseNames' is an ambiguous reference |
using Opc.Ua; and using Opc.Ua.Pumps; both bring a BrowseNames class (and System a Range) |
Alias them: using BrowseNames = Opc.Ua.Pumps.BrowseNames;, using Range = Opc.Ua.Range; |
ArgumentException: '…' declares no child named '…' |
The browse name is not declared by that group, or belongs to another group | Use a generated BrowseNames constant on the group that declares it (e.g. Cavitation is in SupervisionProcessFluid, not Measurements) |
Browse path to Identification/Configuration/Operational/Maintenance does not resolve |
Qualified with the Pumps namespace | Use the DI namespace index; see The model |
InvalidOperationException: DI address space already owned |
AddPumps combined with AddOpcUaDi or another DI owner |
Load the model into the existing manager |
BadBrowseNameDuplicated from CreatePumpAsync |
A pump of that name already exists below the parent | Use unique names, or manager.Pump(nodeId) to reconfigure |
| Machinery clients don't find the pumps | OrganizeIntoMachinesFolder = false |
Leave it on |
| Machinery model fails to load | LoadIndustrialAutomationModel = false |
Leave it on |
ArgumentException: … declares '<Vibration>' as a placeholder |
Nested("Vibration") on a placeholder |
Use AddVibration(name) |
| A measurement has no unit on the client | Server set it without EngineeringUnits, or client read with ValuesOnly |
Pass the unit to SetAnalog; read metadata once with the defaults |
PhysicalAddress or option sets arrive as ExtensionObject |
Client not created through session.Pumps(...) |
Use the extension |
A vendor port comes back as PumpPortKind.Unknown |
Vendor subtype of PortType |
Expected; its groups are still read |
Model provenance
src/Opc.Ua.Pumps/Model/Opc.Ua.Pumps.NodeSet2.xml is the unmodified OPC
Foundation publication from
UA-Nodeset/latest/Pumps:
model version 1.0.0, published 2021-04-19, requiring UA 1.04.7, DI 1.02.2 and
Machinery 1.0.0. The sibling Opc.Ua.Pumps.NodeSet2.csv pins the
symbol-to-NodeId mapping, so a client that caches NodeIds across builds keeps
working; the generator fails the build when the two disagree.
The RequiredModel versions are a lower bound, not a pin. The repository's
DI and Machinery models are newer and satisfy them.
Generator gap this model uncovered
The generated state classes implement
NodeState.CreateChild(context, browseName) for every optional child the
model declares, which is what lets one builder cover all ~3,000 of them. That
generic path creates a child of the right CLR type and browse name, but does
not stamp the modelling metadata: the child comes back with no
ReferenceTypeId, no TypeDefinitionId and BaseDataType as its data type.
(The per-child AddXxx(context) helpers go through the model factory and do
set all of it.)
Such a node looks fine from inside the server and can even be read by NodeId,
but it is invisible to a client: every browse a client makes is filtered
on HierarchicalReferences, and the child has no reference type to match.
The same applies to TranslateBrowsePathsToNodeIds.
PumpGroupBuilder therefore completes each newly created child from its
instance declaration on the type, copying ReferenceTypeId,
TypeDefinitionId, DataType, ValueRank, access levels and sampling
interval before registering it, and filling only members the generic path
left unset. PumpsClientServerE2eTests is what catches a regression here.
See also
- Device Integration developer guide:
DiNodeManager,ConfigureDevicesFor, the DI client - Machinery developer guide: the
Machinesfolder and the OPC 40001-1 building blocksPumpTypeinherits - Subscriptions: monitored items and data-change delivery
- OPC 40223 on the OPC Foundation reference site