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.
- 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
| 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.
| 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.
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.
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.
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);| 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.
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 |
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.
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.
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.
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.
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.
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.
| 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 |
| 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.
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 |
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));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 and created with AddVibration:
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 |
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.
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 publishing triggers monitored
items; 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 = …).
For bounded interactive group reads, choose the overload with an explicit limit:
PumpsClient pumps = session.Pumps(telemetry, maximumBrowseReferences: 256);
PumpValueSet values = await pumps.ReadMeasurementsAsync(
pumpId, new PumpReadOptions { IncludeTimestamps = true }, cancellationToken);The limit applies to each browse and to a value-set read including its nested
groups. Exceeding it fails instead of truncating the result. This mode also
rejects incomplete value-set responses, ambiguous member resolution, duplicate
value names and invalid engineering metadata. Denied or uncertain service
responses are not treated as optional absence. The same overload is available
on the injected PumpsClientFactory.CreateAsync. Constructor and factory
overloads without an explicit limit do not enable this bounded-read mode.
Continuation cleanup accepts an empty successful release response without masking
the original limit error or cancellation. A failed release preserves both failures.
EnumeratePortEntriesAsync returns port identities without reading every
engineering group. Use it to select a port before fetching its values with
ReadValueSetAsync; EnumeratePortsAsync returns complete port
descriptors.
For the desktop workflow, see UaLens Pumps.
| 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.
| 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.
| 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.
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.
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, retain the metadata, then poll with ValuesOnly;
re-read metadata after a device or server configuration change.
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 includes Boolean true readings whose status is not bad.
An empty result means no readable signal is raised; signals that are missing,
non-Boolean or bad are not counted.
PumpMaintenanceData exposes the four categories as value sets plus three
derived answers: StateOfTheItem and MaintenanceLevel (from
GeneralMaintenance) and HasFailure (from BreakdownMaintenance).
| 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.
Status key: ✅ implemented and tested over a real session · 🧪 implemented, tested in-process only · 🔧 implemented, not covered by a test · ❌ not shipped.
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.
| 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 |
| 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 | — |
| 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 |
— |
PumpsServer accepts --host, --port, --pki-root, --autoaccept and
--run-seconds. The PKI path must be absolute; a supplied runtime must be from
1 through 3600 seconds. Without the runtime option, the server runs until
stopped interactively. Certificate auto-accept is enabled by default for sample
convenience; disable it when testing explicit peer trust.
UaLens launches its owned simulator on loopback with private PKI,
--autoaccept false and an explicit finite runtime.
| 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 |
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.
| 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 |
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.
The generated state classes implement
NodeState.CreateChild(context, browseName) for every optional child the
model declares. One builder can therefore 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 catches regressions here.
- 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