Skip to content

Latest commit

 

History

History
1174 lines (974 loc) · 63.7 KB

Pumps.md

File metadata and controls

1174 lines (974 loc) · 63.7 KB

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.

Pumps support is Full in the specification support matrix. Retained identification, diagnostic and limit conditions, event notification and raw/processed/event history are available through explicit configuration. At server conformance publication, the Pump Base and Advanced profile URIs are contributed only when their configured inherited profiles and live pump instances meet the requirements described under profiles and conformance units. The implementation uses the Pump Base URI defined in Table 153 for the Table 155 dependency; the published CAS Base link remains a discrepancy.

Contents

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 Organizes reference from the Machinery Machines folder.

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 and call CreatePumpsRuntime. The DI owner releases registered resources automatically; explicit DisposeMachineryResourcesAsync() calls remain safe. 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 TwoStateDiscreteType variables of the supervision groups (Cavitation, Dry, MotorOverheat, …), which carry the state themselves;
  • the DiscreteInputObjectType / DiscreteOutputObjectType objects of the signal and actuation groups (PumpOperation, …), whose state sits one level down in DiscreteInputValue or DiscreteOutputValue.

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 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 runtime.BindMarkingFile(...)

Documentation files

The Documentation group pairs each document link with a FileType object. runtime.BindDocumentationFile(pump, browseName, content, binder) serves a declared file through the shared IFileTypeBinder; BindMarkingFile creates one named <Marking> below Identification/Markings. The binder enforces session-owned handles, open modes and a byte limit. The address-space owner disposes these bindings.

Without a commit callback, a documentation file is read-only. When supplying one, the application owns replacement validation and durable storage. Serving a file does not grant authorization to replace it; configure the server's method permissions for the intended roles.

Retained writable identification

WithRetainedIdentificationAsync restores AssetId, ComponentName and Location from the shared AMB IAssetConfigurationStore, then enables client writes for roles granted Write. The pump needs a stable, owner-unique ProductInstanceUri; changing its namespace index does not change the stored key. ComponentName retains its LocalizedText locale and text.

using Opc.Ua.AMB.Server.Configuration;

services.AddSingleton<IAssetConfigurationStore>(
    _ => new FileSystemAssetConfigurationStore(stateRoot));

// Inside ConfigurePumps, or after direct runtime creation:
pump.WithNameplate(new PumpNameplate
{
    NodeId = NodeId.Null,
    ProductInstanceUri = "urn:acme:pump:P-1",
    AssetId = "P-1",
    ComponentName = new LocalizedText("en", "Feed pump"),
    Location = "Skid 2"
});
await runtime.WithRetainedIdentificationAsync(
    pump, store,
    [
        new RolePermissionType
        {
            RoleId = Opc.Ua.ObjectIds.WellKnownRole_ConfigureAdmin,
            Permissions = (uint)(PermissionType.Browse | PermissionType.Read | PermissionType.Write)
        }
    ],
    ct);

In a hosting callback, resolve the store with GetRequiredService<T>() and use context.Manager.Runtime; direct construction passes the same store and runtime explicitly. DI owns registered services. Otherwise the application keeps the store alive until its address-space owner stops.

Stored values override configured defaults. A restoration failure leaves the previous values and access levels in place, removes the incomplete bindings, and propagates the error. A write is accepted only after storage succeeds; a failed commit returns BadResourceUnavailable without caching the attempted value. Empty strings can clear the two string fields; null string writes are rejected so that a restart cannot replace an accepted clearing operation with a configured default. Manufacturer-owned fields and ProductInstanceUri remain read-only. A volatile store or a duplicate stable identity is rejected.

The binding publishes writable access and state notifications in AssetId, ComponentName, then Location order. If a notification sink throws, the exception propagates at that field: its mutations remain, later fields are not made writable, and the retained identity is not registered. The installed persistence callbacks remain in place, so calling the binding method again fails because the fields are already bound.

An authorized client uses the typed writes and reads the nameplate again:

await pumps.WriteAssetIdAsync(pumpId, "CW-42", ct);
await pumps.WriteComponentNameAsync(pumpId, new LocalizedText("de", "Speisepumpe"), ct);
await pumps.WriteLocationAsync(pumpId, "Skid 3", ct);
PumpNameplate? updated = await pumps.ReadNameplateAsync(pumpId, ct);

These methods resolve the declared namespace-qualified member with the Pumps client's existing bounds, then use internal generated DI/Machinery accessors and the shared ClientVariable<T>.WriteInstanceAsync operation. The generated codec determines the wire type; the server's instance permissions authorize the write. The operation requires one Good result under a Good service response. Denied, uncertain and malformed responses propagate as errors. Model-declared read-only accessors remain read-only through their ordinary WriteAsync operation. These methods do not write control signals or invoke physical equipment.

Conditions and history

Alarm bindings use the standard condition implementation rather than treating a Boolean supervision value as an event. Select the DI NE107 category explicitly; the library does not infer a manufacturer's diagnosis from a browse name. Every instantiated supervision input needs its own mapping when implementing the Supervision Health Status conformance unit.

var supervision = pump.Supervision(BrowseNames.SupervisionMechanics)
    .SetDiscrete(BrowseNames.BearingFault, false);
var input = (BaseVariableState)supervision.Child(BrowseNames.BearingFault)!;
PumpAlarmBinding diagnostic = await runtime.AddDiagnosticAlarmAsync(
    supervision, input, "BearingFailure", Opc.Ua.Di.DeviceHealthEnumeration.FAILURE,
    cancellationToken: ct);
PumpAlarmBinding limit = await runtime.AddMeasurementAlarmAsync(
    pump.Measurements,
    (BaseVariableState)pump.Measurements.Child(BrowseNames.MassFlow)!,
    "FlowLimit", new PumpMeasurementLimits { Low = 6, High = 32 },
    ct);

Publish input changes first, then await EvaluateAsync(ct) on each affected binding. This explicit evaluation step works with polling and provider-driven acquisition. Evaluation serializes state mutation and awaits state-change notifications while holding the binding gate. A notification callback must not await another evaluation on the same binding. Configured limits must be finite and strictly ordered; NaN omits a limit. Crossings are strict, so equality with a high limit does not cross it. Bad-quality or non-finite samples update condition quality without inventing a normal measurement. Reported events retain the input source timestamp.

The node manager owns each alarm binding and disposes it during shutdown. DisposeAsync() rejects new evaluations, waits for admitted evaluations and their event reports to finish, then releases the evaluation gate. Repeated disposal calls await the same completion. A notification callback must not await disposal of its own binding.

An evaluation records its duplicate-suppression state only after all state notifications finish. If a notification throws, earlier mutations remain visible and the exception propagates; the next evaluation retries the incomplete update using the current input. Completed event snapshots wait in publication order until reporting succeeds. A reporting exception or cancellation leaves the failed snapshot queued for the next evaluation. Retrying can notify a sink again if it received the event before another sink failed. Event delivery remains outside the state-mutation gate.

The condition exposes standard enable/disable, acknowledgment and retained refresh behavior through the shared alarm service. InputNode and SourceNode identify the actual variable. The binding adds HasCondition and GeneratesEvent references, including the applicable group type declaration. Unchanged inputs do not produce duplicate condition events. Applications can configure the returned standard Alarm for further lifecycle options; the binding does not infer physical-control behavior. Overloads accepting PumpAlarmOptions preserve an explicit active severity and can expose standard suppression or shelving methods. Shelving requires a positive MaximumShelvingTime; one-shot shelving ends when the input clears or that maximum expires. Timed shelving ends after the requested duration, without clearing a separately applied suppression or acknowledging the condition.

History is separately opt-in and uses the existing Part 11 providers:

using Opc.Ua.Server.Historian.InMemory;

using var archive = new InMemoryHistorianProvider(new InMemoryHistorianOptions
{
    RawDataRetentionPeriod = TimeSpan.FromMinutes(5),
    MaxSamplesPerNode = 300
});
var flow = (BaseVariableState)pump.Measurements.Child(BrowseNames.MassFlow)!;
archive.Register(flow.NodeId);
archive.Register(pump.NodeId, Opc.Ua.Server.Historian.HistorianNodeCapabilities.EventReadWrite);
await runtime.HistorizeAsync(pump, [flow], archive, cancellationToken: ct);
await runtime.HistorizeEventsAsync(pump, archive, cancellationToken: ct);

Configure selected NodeIds in the provider before activating history, during address-space configuration and before publishing values. No fallback archive is created. Data providers need raw-read capability; event providers need event-read capability. Automatic capture additionally requires the corresponding insert capability. Read-only external archives can disable capture. History activation grants read access, not HistoryWrite, and leaves unselected nodes unchanged. The owner drains capture on shutdown; the application or its DI container owns the provider.

Pumps registers new node routes with ownsProvider: false, so full server shutdown does not dispose an application-owned archive. A custom registry must implement IHistorianProviderOwnershipRegistry for this registration; otherwise activation throws NotSupportedException and restores the affected node metadata. Existing application routes are preserved, including any disposal ownership the application previously transferred to the registry. Failed activation restores existing annotation access levels and removes newly created historical configuration and annotation nodes together with their explicit references.

Raw capture preserves status and source timestamps. Processed reads use the shared provider/aggregate dispatcher, and event history snapshots the same conditions delivered live. Clients reuse pumps.Session.Historian() for ReadRawAsync, ReadProcessedAsync and ReadEventsAsync; standard condition operations use session.GetAlarmClient(telemetry). These services are not a second pump-specific historian or alarm client.

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 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).

When acquisition already supplies quality and source time, use await runtime.PublishValueAsync(group, browseName, in dataValue, ct) instead. It preserves those fields and awaits asynchronous state-change notifications after invoking synchronous observers. Cancellation is checked before mutation; after assigning the sample, notifications complete without cancellation. If a sink throws, the exception propagates and the acquired sample remains visible. The variable must already exist and remain registered under a pump owned by the runtime. Publication does not materialize an absent optional member, and a stopped runtime rejects updates.

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

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.

Identity writes require exactly one complete, session-local target at each resolution step, including when the client was created without a browse limit. Missing or ambiguous identity members prevent the write; cancellation between resolution steps or before dispatch prevents subsequent requests.

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.

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, not DiscreteInputValue).

  • Other nested OPC 40223 objects are groups of their own and come back in PumpValueSet.Groups, one level deep: the <Vibration> instances below Measurements, for example.

    foreach ((string point, PumpValueSet vibration) in measurements.Groups)
    {
        double? velocity = vibration.GetDouble(BrowseNames.OverallVibrationVelocityRMS);
    }

    Objects of the base models, such as the FileType documents, 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, retain the metadata, then poll with ValuesOnly; re-read metadata after a device or server configuration 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 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.

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 Advertised when every current pump has writable Machinery identification and the required inherited server profiles are present
Pump Advanced Server Profile (Table 155) http://opcfoundation.org/UA-Profile/Pumps/Server/Advanced Advertised when Pump Base and every required inherited Advanced server profile are present

The owner computes these profiles from the current registered pumps and the server's ServerProfileArray when conformance is published. It does not infer a profile from loaded types, an enabled option, or an optional native conformance unit.

Standalone and composed Pumps runtimes contribute native units through the owner's IConformanceContributor mechanism. Evaluation uses registered instances when the owner reads its contributions; it does not create optional members. Titles retain the spelling in Table 152 and are qualified with the Pumps model namespace to distinguish generic names such as Base System. That namespace is an implementation choice, not a requirement established by OPC 40223.

All-instance rules require at least one pump. Removing mandatory nodes or read access withdraws the affected identification unit; every current pump needs a legal PumpClass for PumpClass Data. A supervision unit requires a registered DI diagnostic condition for every instantiated supervision signal, and limit-alarm publication requires a registered bound condition as well as the four measurement-type declarations. History requires a quantity with history access and an active shared data-provider route. Removed nodes and routes no longer contribute, and a disposed runtime contributes no native units.

The published profile tables define two different URIs. OPC 40223 Table 153 defines the Pump Base URI as http://opcfoundation.org/UA-Profile/Pumps/Server/Base, while Table 155 names the Pump Advanced Server Profile and its mandatory Pump Base Server Profile row links to http://opcfoundation.org/UA-Profile/CAS/Server/Base. OPC 40250-1 Table 163 independently defines that second URI as CAS Base, and Table 172 assigns it CAS-specific mandatory requirements. OPC 40223 Table 154 specifies the Pump Base profile's own requirements; OPC 40223 Table 155 is titled Pump Advanced Server Profile. The implementation uses the Pump Base URI defined in Table 153 for Table 155's Pump Base dependency. The published CAS Base link remains a discrepancy; this interpretation does not advertise CAS Base or add its CAS-specific requirements.

Pump Base requires the Embedded 2017 UA Server, Data Access, ComplexType 2017, and Aggregate Subscription profiles from Table 154. It also requires every current pump to have complete writable Machinery identification. The runtime keeps the Pumps NodeSet declaration unchanged and uses HasAddIn for the instance identification edge; HasAddIn is a subtype of the declared HasComponent reference and satisfies Machinery's identification relationship. The writable AssetId, ComponentName and Location members are enabled by WithRetainedIdentificationAsync after the application supplies a persistent store and role permissions that grant Write to at least one role.

Table 154 includes Aggregate Subscription among the Pump Base requirements. Shared-server tests in AsyncCustomNodeManagerTests, including ValidateMonitoringFilterAsyncUsesHistorianCapabilitiesAsync, ValidateMonitoringFilterAsyncUsesSamplingIntervalWithHistorianAsync and ReadInitialValueAsyncPrimesAggregateFilterFromPagedRawHistoryAsync, exercise aggregate-filter validation and history priming. They provide evidence for shared Aggregate Subscription behavior; the Pumps profile evaluator separately requires the corresponding profile URI in the server's ServerProfileArray. The priming test applies only to the async manager.

Advanced also requires the Address Space Notifier, A&C Address Space Instance, A&C Exclusive Alarming, Base Historical Event, Historical Raw Data and Historical Aggregate server profiles. The evaluator advertises Advanced only when each URI is present and Pump Base is eligible. The native optional units remain independent capabilities; enabling or omitting them does not alter eligibility for either profile.

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 🔧 DeviceTypeEnum values 0–6 are checked before changing Design/PumpClass
Pump Connection Port ✅ Inlet and outlet connection ports
Pump Drive Port ✅ Drive port
Pump Control 🧪 Control and actuation model members; no physical pump executor
Historizing 🔧 Explicit shared raw/processed/event provider integration
Limit Alarm Status 🔧 Exclusive measurement conditions and group-type GeneratesEvent declarations
Supervision Health Status 🔧 Explicit DI NE107 diagnostic subtypes per configured input

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 PumpBuilderTests
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 and <Marking> files 🔧 PumpFileExtensions, shared IFileTypeBinder PumpBuilderTests
Retained writable identification with explicit permissions 🔧 PumpIdentificationExtensions, shared AMB persistence PumpsIdentificationTests
CU Supervision Health Status: DI NE107 diagnostic alarm subtypes 🔧 PumpsAlarmExtensions, shared condition implementation PumpsAlarmTests; PumpsHistoryTests
CU Limit Alarm Status: exclusive limit alarms 🔧 same PumpsAlarmTests
Raw, aggregate and event historian integration 🔧 PumpsHistoryExtensions, shared Part 11 services PumpsHistoryTests
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
Typed identity writes with status/cardinality checks 🔧 PumpsClient.Writes PumpsBoundedReadTests
Raw/processed/event history and condition operations 🔧 Shared HistoryClient and AlarmClient over PumpsClient.Session PumpsHistoryTests
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

PumpsServer accepts --host, --port, --pki-root, --state-root, --autoaccept and --run-seconds. PKI and state paths 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.

The server binds every sample supervision input to a diagnostic condition and adds a bearing-temperature limit condition. Flow history retains five minutes, up to 300 samples per pump; event history is process-local and is not retained on restart. The sample serves an illustrative manual and marking through the shared file service. --state-root enables retained identity for ConfigureAdmin; the application must still authenticate users and assign that role. Without the option, no retained identity writes are enabled. The client --history option reads raw flow, minute averages and recorded conditions. --conditions refreshes retained conditions and watches subsequent events for the first pump. Neither option acknowledges alarms or changes equipment.

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

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
PumpsConformanceTests Native-unit evaluation; Pump Base/Advanced eligibility across identity permissions, each inherited profile prerequisite, pump removal and runtime shutdown
PumpsIdentificationTests Restart, locale, commit and restore failures, stable identity, ordered notification failures
PumpsAlarmTests NE107 mappings, limit boundaries, condition lifecycle and awaited state-notification ordering
PumpsHistoryTests Provider selection/capabilities and real-session raw/aggregate/event history with retained-condition refresh
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. 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.

See also