Files
UA-.NETStandard/docs/Machinery.md
Marc Schier 1bb4bdca1d Add Machine Vision Parts 1 and 2 (#4733)
# Description

Implement the released [OPC
40100-1](https://reference.opcfoundation.org/specs/OPC-40100-1) and [OPC
40100-2](https://reference.opcfoundation.org/specs/OPC-40100-2)
specifications with generated models, provider-backed runtimes, typed
clients, MCP workflows, and DI/Machinery integration.

- **Part 1 stays in `Opc.Ua.Vision`** and its client/server packages
alongside the distinct Perception model. Provide control/acquisition
state machines, jobs and synchronized steps,
recipe/product/configuration/result management, file transfers,
diagnostics, events, and conditions.
- **Part 2 is in `Opc.Ua.MachineVision`, `.Client`, and `.Server`.**
Provide asset categories, identification, image-handling aspects,
health/maintenance data, topology, and transactional updates. The base
Vision assemblies do not reference these optional packages; the Part 2
wire model does not depend on Part 1.
- Preserve the release-pinned model bytes, wire namespaces and NodeIds.
Perception types are in `Opc.Ua.Vision.Perception`; the AMCM generated
namespace remains `Opc.Ua.Vision.MachineVision.AMCM`, and asset
client/server extension namespaces remain `Opc.Ua.Vision.Client` and
`Opc.Ua.Vision.Server`.
- Use `AddOpcUaMachineVision` and `AddOpcUaMachineVisionAMCM` for model
loading and factory registration. Each has node-collection,
encodeable-factory-builder and node-state-factory-builder overloads.
These short entry points delegate to the generated implementations;
existing namespace-derived generated names remain available for
compatibility.
- Support multiple independent named systems/assets under one
namespace-owning manager. Reuse injectable providers, file binding,
bounded memory stores, optional transactional `IFileSystemProvider`
persistence, state machines, telemetry and conformance infrastructure.
- Keep one `docs/Vision.md` guide, titled `Machine Vision (OPC 40100-1
and OPC 40100-2)`, and one `Opc.Ua.Mcp.Vision` package. Include
deterministic server/client samples.
- Expose 17 Machine Vision MCP tools, including capabilities/status,
bounded relationships and observation, complete small-file
uploads/downloads, management, explicit control, conditions, and chunked
transfers. Catalogue counts: 39 Vision-specific, 43 with connection
tools, 81 Vision+Robotics, 155 Full. Every Machine Vision wire parameter
has a schema description.

**Compatibility and limits:** `AddVision()` remains Perception-only
unless released parts are selected. Standalone Part 2 uses
`AddVision().AddMachineVision()`; Machinery composition uses
`AddVisionToMachinery()`. Existing tool defaults and command signatures
are preserved. Observation owns and cleans up its subscription: 5
seconds/50 notifications by default, capped at 60 seconds/100
notifications. Complete-file tools are capped at 1 MiB; larger files use
chunked transfers. Domain Error values remain distinct from UA service
failures.

**Versioning:** the seven Vision/MachineVision/MCP packages and their
Debug variants follow the repository root version instead of being
forced to preview. Vision.OpenUsd and its descendants remain
preview-only. Development builds still inherit the root prerelease
version. No release version, tag, release branch, publication policy, or
feed promotion is changed.

**Review actions:** all fourteen review threads have action/evidence
replies and are resolved. Changes include the unified guide, Perception
namespace, separate Part 2 packages and final naming, multi-system
ownership explanation, interrupted-write cleanup, observer failure
isolation, complete MCP parameter descriptions, and explicit package
inventory. The preview-layout migration topic and the requested
ReleaseProcess caveat were removed. The test Publish collector now
retries recognized transient failures within one cancellation budget and
propagates other exceptions. Commit 69b1f5db9 applies the requested
short loader names and renames standalone hosting to `AddMachineVision`,
with callers, tests and documentation updated. The intervening master
merge is preserved.

### Verification

**Latest API naming changes, verified locally:**

| Scope | Result |
| --- | --- |
| Full Vision suite, net10 | 572 passed, 1 existing skip, 0 failed |
| Full Vision suite, net48 | 515 passed, 0 skipped, 0 failed |
| Final loader/hosting cases after cross-TFM null-guard adjustment | 9
passed, 0 failed, 0 skipped |
| Machine Vision MCP and profile cases | 198 passed, 0 failed, 0 skipped
|
| Main and MCP AOT test-project compilation | Both built with 0
warnings/errors |
| MCP AOT companion, managed execution | 5 passed, 0 failed, 0 skipped;
not claimed as native execution |

`ModelLoaderNamesRegisterTypedFactories`,
`ModelLoaderNamesRejectNullArguments`, and
`GeneratedModelsLoadTogetherAndCreateTypedInstances` cover all six
loader overloads, fluent return identities, typed instances, namespace
coexistence and null guards. Existing standalone Part 2 tests verify
idempotent registration, one-owner enforcement and null handling through
`AddMachineVision`. No source-generator implementation, CLR namespace,
wire identity or model payload was changed for these naming updates.

The current 69b1f5db9 revision is running
[CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826937),
[package
validation](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165823359),
[MCP
usability](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826874),
[CodeQL](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826906),
and [Images
CI](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38165826884).
These runs are not claimed as completed results.

**Earlier completed verification:** all seven default-TFM package
archives were inspected after the package rename: exact IDs, 27 clean
DLLs, correct dependencies/readmes/licenses, and unchanged pinned model
hashes. [CI on
c75ec4fa6](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/38142519760)
passed, including the formerly failing Linux batch and required summary.
Its unchanged coverage gate passed at **88.56% lines, 78.96% branches,
84.28% patch** against the 75% patch floor. The later
bounded-Publish-helper regressions reproduced 13 failures before the fix
and passed afterward; the full mainline triggering fixture passed 67
tests on each of net10/net48. These are historical results, not current
full-matrix results.

<details>
<summary>Executed API naming validation commands and outcomes</summary>

Run from the worktree root using the established artifact trees.
Dependency builds were performed before the no-build full net10 run; the
focused loader/hosting selection was rebuilt and rerun after the final
null-guard adjustment.

```powershell
$files = 'C:\Users\mschier\.copilot\session-state\deb2ba10-b0e9-4a44-8783-8331bc850814\files'
$env:DOTNET_CLI_USE_MSBUILD_SERVER = '0'
$env:TEMP = 'C:\Temp\mv-deb2\followup'
$env:TMP = $env:TEMP

# Passed: 572; Failed: 0; Skipped: 1.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-build --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net10" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 9; Failed: 0; Skipped: 0, on the final source.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~MachineVisionModelTests|FullyQualifiedName~StandalonePartTwoRegistrationUsesOneOwnerAndOneModelProvider|FullyQualifiedName~StandalonePartTwoRequiresRegistrationAndRejectsCompetingOwnership' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-model" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

# Passed: 198; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Tools.Tests\Opc.Ua.Tools.Tests.csproj -c Release -f net10.0 --no-restore --artifacts-path artifacts\vision-followup -p:CustomTestTarget=net10.0 -p:UseSharedCompilation=false -m:1 -nr:false --filter 'FullyQualifiedName~VisionMachineToolsTests|FullyQualifiedName~McpHostBuilderTests|FullyQualifiedName~McpToolProfileCompositionTests' --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-final-mcp" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup'

$env:TEMP = 'C:\Temp\mv-deb2\followup48'
$env:TMP = $env:TEMP

# Passed: 515; Failed: 0; Skipped: 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -c Debug -f net48 --no-restore --artifacts-path "$files\solution-net48-artifacts" -p:CustomTestTarget=net48 -p:UseSharedCompilation=false -m:1 -nr:false --logger 'console;verbosity=minimal' --logger trx --results-directory "$files\vision-api-names-net48-final" -- 'NUnit.WorkDirectory=C:\Temp\mv-deb2\followup48'
```

Opt-in long-running/stress/durable tiers have not been rerun locally;
the unqualified **all tests** checklist item remains open. Original
implementation matrix counts are not presented as current results.

</details>

## Related Issues

- Fixes #4732.

The requester reviewed and approved the implementation plan and
subsequent packaging/API decisions in the development session. The
tracking issue records the design; maintainer architectural
discussion/sign-off there is still pending. This PR remains a draft.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-12 07:01:27 +00:00

58 KiB

OPC UA for Machinery (OPC 40001)

Released Machine Vision (OPC 40100-1/-2) composes into this manager through AddVisionToMachinery() and ConfigureVisionFor<MachineryNodeManager>(). This reuses the existing DI/Machinery owner; do not register a second asset-owning Vision manager.

The Opc.Ua.Machinery* packages implement the OPC 40001 specification series — the building blocks every machine shares, plus the four specialised parts that production equipment adds depending on its use case.

Contents

Library layout

Each part is its own model assembly, so a server takes only what it serves. A machine tool that reports jobs and results does not have to carry PADIM.

Package Specification Model URI
Opc.Ua.Machinery OPC 40001-1 Basic Building Blocks http://opcfoundation.org/UA/Machinery/
Opc.Ua.Machinery.ProcessValues OPC 40001-2 Process Values …/UA/Machinery/ProcessValues/
Opc.Ua.Machinery.Jobs OPC 40001-3 Job Management …/UA/Machinery/Jobs/
Opc.Ua.Machinery.Energy OPC 40001-4 Energy Management …/UA/Machinery/Energy/
Opc.Ua.Machinery.Result OPC 40001-101 Result Transfer …/UA/Machinery/Result/

Two more packages turn those models into a running server and a client:

Package What it adds
Opc.Ua.Machinery.Server MachineryNodeManager, the fluent machine builder, the state-machine controllers, the OPC 40001-101 download path, and the AddMachinery() hosting pipeline
Opc.Ua.Machinery.Client MachineryClient — discovery from the Machines folder, identification, state observation, job control and result download

Models and namespaces

The generated C# namespace of each model is derived from its model URI by NodeSetToModelDesign.CreateNamespace, and every project pins the derived value explicitly — it is part of the contract each assembly publishes through [assembly: ModelDependencyAttribute] (see ModelDependencies.md).

Model URI C# namespace Namespaces member Model loader
…/UA/Machinery/ Opc.Ua.Machinery Machinery AddOpcUaMachinery
…/UA/Machinery/ProcessValues/ Opc.Ua.Machinery.ProcessValues MachineryProcessValues AddOpcUaMachineryProcessValues
…/UA/Machinery/Jobs/ Opc.Ua.Machinery.Jobs MachineryJobs AddOpcUaMachineryJobs
…/UA/Machinery/Energy/ Opc.Ua.Machinery.Energy MachineryEnergy AddOpcUaMachineryEnergy
…/UA/Machinery/Result/ Opc.Ua.Machinery.Result MachineryResult AddOpcUaMachineryResult
…/UA/IA/ Opc.Ua.IA IA AddOpcUaIA
…/UA/ECM/ Opc.Ua.ECM ECM AddOpcUaECM
…/UA/PADIM/ Opc.Ua.PADIM PADIM AddOpcUaPADIM
…/UA/Dictionary/IRDI Opc.Ua.IRDI IRDI AddOpcUaIRDI

Note on placement: the Machines folder (FolderType) that OPC 40001-1 defines is Organizes-referenced from Objects, not from the DI DeviceSet. There is no MachineryItems folder in 1.04.1, and no MachineryBuildingBlocksType — the building blocks are AddIn instances (Identification, Monitoring, Components, MachineryEquipment, Notifications).

Dependency specifications

Adding OPC 40001 pulled in three specifications the repository did not have, and moved a fourth out of Opc.Ua.Robotics:

Package Specification Why
Opc.Ua.IA OPC 10000-200 Industrial Automation 40001-1 types MonitoringType.Status.Stacklight with IA's BasicStacklightType. IA used to be generated inside Opc.Ua.Robotics; two assemblies generating the same model URI under the same C# prefix collide at the consumer, so it moved to its own package and Robotics now references it. The generated types keep living in the Opc.Ua.IA namespace and AddOpcUaIA is unchanged.
Opc.Ua.PADIM OPC 30081 PADIM + the IRDI dictionary 40001-2 derives ProcessValueType / ProcessValueVariableType from PADIM's AnalogSignalType / AnalogSignalVariableType. IRDI ships in the same assembly because PADIM references it several hundred times and is unusable without it.
Opc.Ua.ECM OPC 34100 Energy Consumption Management 40001-4's energy carriers are instances of ECM's EnergyMeasurementValueType.
Opc.Ua.ISA95 (existing) OPC 10031-4 ISA-95 Job Control V2 40001-3's JobManagementType composes the Job Control V2 receiver and response provider.

The resulting reference graph:

Opc.Ua.Core
 └── Opc.Ua.Di ──┬── Opc.Ua.IA ──┬── Opc.Ua.Robotics
                 │               ├── Opc.Ua.ECM ─── Opc.Ua.Machinery.Energy
                 │               └── Opc.Ua.Machinery
                 └── Opc.Ua.PADIM (+ IRDI) ─── Opc.Ua.Machinery.ProcessValues
Opc.Ua.Core ─── Opc.Ua.Machinery.Result
Opc.Ua.ISA95 ─── Opc.Ua.Machinery.Jobs

Quick start — server

builder.Services
    .AddOpcUa()
    .AddServer(options =>
    {
        options.ApplicationName = "MachineryServer";
        options.ApplicationUri = "urn:localhost:OPCFoundation:MachineryServer";
        options.EndpointUrls.Add("opc.tcp://localhost:62546/MachineryServer");
    })
    .AddMachinery(options => options.Parts = MachineryParts.All)
    .ConfigureMachinery(async machinery =>
    {
        IMachineHandle<BaseObjectState> press = await machinery
            .AddMachine("Press-1")
            .WithIdentification(id =>
            {
                id.Manufacturer = new LocalizedText("Acme");
                id.Model = new LocalizedText("P-500");
                id.SerialNumber = "SN-0001";
                id.ProductInstanceUri = "urn:acme:press:1";
            })
            .WithMonitoring(monitoring => monitoring
                .WithMachineryItemState()
                .WithOperationMode())
            .BuildAsync();

        // The handle is how the application drives the machine afterwards.
        await press.ItemState!.SetStateAsync(MachineryItemStateValue.NotExecuting);
    });

Nothing reaches the address space until BuildAsync runs, and a failure anywhere in the chain rolls the machine back — the server is never left with half a machine.

MachineryParts decides which models the manager loads, which namespaces it announces and which conformance units it can advertise. Trim it so the server carries only what it serves:

Value Needs
BuildingBlocks DI + IA
ProcessValues BuildingBlocks + PADIM + IRDI
Jobs ISA-95 Job Control V2 only — no DI
Energy BuildingBlocks + ECM
Result UA core only — no DI, no machine model

A server that publishes nothing but measurement results skips the machine model entirely:

builder.Services
    .AddOpcUa()
    .AddServer(/* … */)
    .AddMachineryResults(options => options.RootBrowseName = "ResultManagement");

That registers MachineryResultNodeManager, which shares its method binder with the ResultManagement object below a machine, so both answer a client identically.

Quick start — client

MachineryClient machinery = session.Machinery(telemetry);

await foreach (MachineEntry machine in machinery.EnumerateMachinesAsync(ct))
{
    MachineIdentification? id = await machinery
        .ReadIdentificationAsync(machine.NodeId, ct);
    FiniteStateSnapshot? state = await machinery
        .GetItemStateAsync(machine.NodeId, ct);

    Console.WriteLine(
        $"{id?.Manufacturer.Text} {id?.Model.Text} — {state?.CurrentState.Text}");
}

Every accessor returns null rather than throwing when a machine does not publish the block: OPC 40001-1 makes nearly everything optional, so a client that insists on a block would work against one vendor and fail against the next.

Server hosting model

AddMachinery() registers MachineryNodeManager, a DiNodeManager subclass — the same shape RoboticsNodeManager has. One manager serves every configured part, because the parts share one instance tree: a machine's process values, jobs, energy carriers and results all hang off the same object.

ConfigureMachinery(...) runs during server startup, after the models are loaded and the address space is wired. Three overloads take a delegate (sync, async, async with token) and one takes an IMachineryConfigurator class, which is the one to reach for when the configuration needs services from the container.

Instances are minted into an application-owned namespace, MachineryServerOptions.InstanceNamespaceUri (default urn:opcua-netstandard:machinery:instances). It must not be a model namespace, and the options validate that.

Coexisting with a server that already owns DI or ISA-95

AddMachinery() claims the Device Integration address space, and — when the Jobs part is selected — the ISA-95 namespaces as well. A second registration that would load the same models fails immediately with a message naming the current owner, rather than producing a server with two DeviceSet trees.

A server that already owns either one loads the Machinery models into its own manager and drives them through the same builder:

protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
    ISystemContext context, CancellationToken ct = default)
{
    var nodes = new NodeStateCollection();
    nodes.AddMachineryTypeSystem(context, MachineryParts.BuildingBlocks);
    nodes.AddOpcUaPumps(context);
    return new ValueTask<NodeStateCollection>(nodes);
}
builder.ConfigureMachineryFor<PumpNodeManager>(async machinery =>
{
    await machinery.AddMachine(pump, new QualifiedName("Pump-1"))
        .WithIdentification(/* … */)
        .BuildAsync();
});

AddMachine<TState>(TState machine, …) adopts an instance a companion specification created — an OPC 40223 PumpState, an OPC 40223-style GeneratorSetState — so the vendor model keeps its own type definition and still answers the Machinery browse paths. The custom manager only has to expose an IMachineryNodeIdFactory; MachineryNodeManager implements it, and a custom manager implements it with a thread-safe allocator for the configured instance namespace.

Builder reference

OPC 40001-1 building blocks

Every block is attached to its machinery item with HasAddIn, which is what OPC 40001-1 uses — not HasComponent.

Call What it adds
WithIdentification(…) Identification, typed MachineIdentificationType. The browse name comes from the DI namespace because the type derives from DI's FunctionalGroupType. Manufacturer, SerialNumber and (for a machine) ProductInstanceUri are mandatory and validated.
WithMonitoring(…) Monitoring, and below it Status/MachineryItemState, Status/MachineryOperationMode, Status/Stacklight (OPC 10000-200 IA), Health, Process, Consumption — each only when asked for
WithComponents(…) Components, and one AddComponent per machine component. A component gets the narrower MachineryComponentIdentificationType.
WithMachineryEquipment(…) MachineryEquipment and the equipment below it. MachineryEquipmentTypeId is mandatory and required by the call; WithEquipmentLife(…) adds the DI LifetimeVariableType the equipment interface declares.
WithNotifications(…) Notifications, with EventNotifier set, registered as a root notifier, and a publisher the application reports its own events on
WithOperationCounters(…) OperationCounters — PowerOnDuration, OperationDuration, OperationCycleCounter
WithLifetimeCounters(…) LifetimeCounters and one DI LifetimeVariableType per AddLifetimeVariable

The MachineryBuildingBlocks organizer

OPC 40001-1 §7.1 puts the building blocks below a FolderType object named MachineryBuildingBlocks (Machinery namespace), referenced from the machinery item with HasComponent, and the blocks themselves with HasAddIn from that folder. Table 12 of the specification says the item state, the operation mode, both counters, monitoring, equipment and notifications shall be reachable that way, and identification and the components folder may be.

This is not presentation. Every conformance unit for those blocks is worded "has this AddIn under its MachineryBuildingBlocks folder", and OPC 40001-3's Machinery Job Management Base is worded the same way — so a server without the folder satisfies none of them. The builder creates it on first use and references every block it builds; §7.3 forbids duplicating the node, so the second path is a reference to the same node, not a copy. The blocks keep their direct HasAddIn from the machinery item as well, which §7.1 recommends as the top-level entry point.

There is no MachineryItems folder and no MachineryBuildingBlocksType in 1.04.1 — both appear in older drafts and in reduced NodeSet copies. The folder has no type of its own: it is a plain FolderType identified by its browse name, which is why the builder assembles it rather than a generated factory. The Machines folder is Organizes-referenced from Objects.

Default instance browse names are load-bearing

Every OPC 40001-1 conformance unit is worded "using the DefaultInstanceBrowseName", so the namespace a block's browse name sits in is part of the contract, and it is not always the Machinery one: MachineryOperationCounterType declares 2:OperationCounters — the Device Integration namespace — because OPC 40001-1 reuses the OPC 10000-100 functional group so a client written against Device Integration finds it. MachineryItemIdentificationType and its two subtypes do the same with 2:Identification. MachineryNodeSetConformanceTests pins all ten values against the vendored NodeSet.

OPC 40001-2 process values

.WithProcessValue(new QualifiedName("OilTemperature"), pv => pv
    .WithEngineeringUnits(degreeCelsius, new Range { Low = -20, High = 120 })
    .WithLimits(lowLow: 5, low: 15, high: 65, highHigh: 80)
    .WithSetpoint(45)
    .WithDeviationLimits(low: -10, high: 10)
    .WithPercentageValue()
    .WithStatus()
    .WithLimitAlarm()
    .WithDeviationAlarm()
    .WithZeroPointAdjustment(async (processValue, ct) => await ZeroAsync(ct))
    .WithValue(45)
    .Bind(out IProcessValueHandle oilTemperature))

The application drives the value through the handle afterwards, and everything derived from it follows:

await oilTemperature.SetValueAsync(70.0);   // percentage, both alarms
await oilTemperature.SetSetpointAsync(55);  // deviation alarm

WithLimitAlarm() and WithDeviationAlarm() do more than create the objects the model declares. Each one mirrors the limits onto the alarm — absolute limits from WithLimits, deviations from WithDeviationLimits — points the condition at the signal as its InputNode, gives it the source, name and enable state OPC 10000-9 needs, registers it as a root notifier, and re-evaluates it on every SetValueAsync. An alarm that is only created never activates and never reports, which is what the objects alone amount to.

WithZeroPointAdjustment binds the optional OPC 30081 method and reports a ZeroPointAdjustmentEventType event on every call — the part's conformance unit says all instances supporting the method do — with the handler deciding only the ZeroPointAdjustmentResult status the event carries. Because that event type is abstract, the event is reported with a concrete subtype; see Abstract event types.

ProcessValueType derives from PADIM's AnalogSignalType, and its mandatory AnalogSignal child keeps PADIM's AnalogSignalVariableType as its declared type — OPC 40001-2 does not narrow it. What the part adds is ProcessValueVariableType, a subtype of that variable type carrying LowLowLimit / LowLimit / HighLimit / HighHighLimit and PercentageValue. An instance may use a subtype in a declared slot, so the builder puts the richer type there and the limits become reachable. The generated factory alone would leave the PADIM type in place and WithLimits(...) would have nowhere to write.

Substituting the subtype costs one repair. Two PADIM members the derived types inherit used to come out of the generator without their reference type — and, for EngineeringUnits, without its namespace-0 browse name — when ProcessValueVariableType is instantiated on its own, which is exactly the path the substitution takes. A node whose ReferenceTypeId is null is in the node tree but produces no reference in a filtered Browse, so no client ever sees it: SignalTag is mandatory on PADIM's AnalogSignalType, and EngineeringUnits is where every Data Access client reads the unit — and what the 0:Data Access AnalogUnitType unit of the OPC 40001-2 base facet rests on. The generator carries both through the dependency payload now; see Generator gaps.

OPC 40001-3 job management

.WithJobManagement(jobs => jobs
    .WithJobOrderReceiver(provider)
    .WithJobResponseProvider(provider)
    .WithJobOrderCatalog(provider))

JobManagementType has exactly two mandatory children: JobOrderControl, an ISA-95 JobOrderReceiverObjectType, and JobOrderResults, an ISA-95 JobResponseProviderObjectType. The eleven job verbs — Store, StoreAndStart, Start, Stop, Abort, Pause, Resume, Clear, Cancel, Update, RevokeStart — belong to the ISA-95 type, not to OPC 40001-3, and are bound by the shared Isa95JobControlV2Binder that Isa95NodeManager uses. A client therefore sees the same verbs with the same semantics whether it reaches them through a stand-alone ISA-95 root or through a machine.

When no provider is supplied explicitly the builder resolves IIsa95JobOrderReceiverV2 from the application services, so AddInMemoryIsa95JobControlProvider() is enough for a demo server.

MachineryEndToEndTests.JobControlPausesResumesAndRevokesStartThroughAMachineAsync drives Pause, Resume and RevokeStart through a machine over opc.tcp, both the accepted transitions and the refused ones (revoking a running order, pausing one that is not running, resuming one that is), and checks the state the published JobOrderList reports after each.

JobOrderList and JobOrderResponseList are snapshots the builder keeps current from the provider's change streams, because a machine changes its orders by routes that never pass an OPC UA method: IIsa95JobStatusSourceV2 and IIsa95JobOrderCatalogChangeSource refresh both lists, and IIsa95JobResponseChangeSource refreshes the response list when the machine reports a response while the order keeps running — progress such as RunsCompleted, which changes neither the order's state nor the catalog. InMemoryIsa95JobControlProvider implements all three.

OPC 40001-4 energy

.WithEnergy(energy => energy
    .AddResource(MachineryEnergyCarrier.CompressedAir, air =>
    {
        air.Main
            .WithApplicationTag("press/CompressedAir")
            .WithNonElectricalEnergy(importHighPrecision: 0, exportHighPrecision: 0)
            .WithVolumeFlow(volume: 184_320f, volumeFlowRate: 18.4f)
            .WithBaseFlow(pressure: 620_000f, temperature: 294.65f);

        air.AddMeteringPoint(
            new QualifiedName("ClampingCylinders"),
            point => point.WithNonElectricalEnergy(0, 0));
    }))

OPC 40001-4 is an extension of the OPC 40001-1 Monitoring building block rather than a block of its own (§6.1), and its structure is three levels deep:

Monitoring
  Consumption                    ← OPC 40001-1 MonitoringType
    CompressedAir                ← well-known FolderType browse name, §9.1
      Main                       ← well-known EnergyMeasurementType name, §9.2
      ClampingCylinders          ← further metering points

A metering point is an OPC 34100 EnergyMeasurementType object that implements INonElectricalEnergyType and the flow interfaces; the interfaces are abstract, so nothing is ever an instance of them. WithEnergy materialises the monitoring add-in and the Consumption folder when the machine does not already publish them, and AddResource always creates Main — the Machinery Energy Main grouping unit requires one for each resource folder, so a resource without it would make the server non-conformant for every other resource too.

Contains (§8.1) runs from the main metering point to a sub-meter whose readings are part of it. Both ends must be EnergyMeasurementType objects, which is why it never points at a resource folder or at the same-named Object the Energy model carries — those Objects exist only to publish the well-known browse names.

Electricity is the one resource OPC 40001-4 describes with no interfaces of its own: §6.5 defers it to OPC 34100's energy profiles. WithInterface and AddMeasurementValue are how a metering point takes one:

.AddResource(MachineryEnergyCarrier.Electricity, electricity =>
    electricity.Main
        .WithApplicationTag("press/Electricity")
        .WithInterface(NodeId.Create(
            Opc.Ua.ECM.ObjectTypes.IEnergyProfileE1Type,
            Opc.Ua.ECM.Namespaces.ECM,
            context.Context.NamespaceUris))
        .AddMeasurementValue(
            new QualifiedName(Opc.Ua.ECM.BrowseNames.AcActivePowerTotal),
            Variant.From(41_250.0f),
            watt))

The units OPC 40001-4 pins are the UNECE Recommendation 20 codes from its attribute tables and are applied by the builder: watt hours for the two energy readings, pascal for Pressure, kelvin for Temperature.

OPC 40001-101 results

.WithResultManagement(results => results
    .WithInMemoryStore(capacity: 32)
    .WithResultsFolder(publishedResults: 4)
    .WithFileTransfer())

All five methods of ResultManagementType are optional; the builder publishes GetLatestResult, GetResultById, GetResultIdListFiltered, AcknowledgeResults and ReleaseResultHandle once a store is bound, and the ResultTransfer object only when downloads are enabled.

Bringing your own store

Substitute a real store by implementing IMachineryResultStore and passing it to WithStore(...), or by registering it in the container. The machine's result publisher (IMachineHandle.Results, and Publisher on the stand-alone MachineryResultNodeManager) works with any store:

  • a store that also implements IWritableMachineryResultStore receives every published result through AddResultAsync — InMemoryMachineryResultStore is one;
  • a store that fills itself through a path of its own — a database a PLC gateway writes to — does not implement it. Publishing then only announces the result: the result variables and the ResultReadyEventType event. The store has to hold the result already; one it cannot return from GetResultByIdAsync is refused with Bad_InvalidState, because an event must not announce a result the methods cannot hand out.

With WithPredefinedResultMetaData() the metadata check runs before the store sees the result, whichever kind of store it is.

Filtering and ordering the result list

GetResultIdListFiltered evaluates both arguments OPC 40001-101 §7.1.3 defines:

  • filter is a ContentFilter whose BrowsePaths "can be built from the fields of the ResultReadyEventType, the ResultType VariableType or the ResultDataType". All three spellings resolve to the same field — an optional leading Result, then ResultMetaData (with its fields, and ProcessingTimes one level further down) or ResultContent — and names are matched without their namespace index, because a DataType field has none. The comparison, logical and Like/Between/InList/OfType/bitwise operators are supported; InView and RelatedTo need a node and are FALSE.
  • orderedBy sorts by each RelativePath in turn, ascending. Results that leave a key unset sort last, and results that tie keep the store's newest-first order. An empty orderedBy "executes no ordering", so the store's own order stands.
  • maxResults caps the list after filtering and ordering; zero means no cap.

Most metadata fields are optional, so NULL handling decides what a filter returns. The evaluator follows OPC 10000-4 §7.7.3: a comparison with an unset field is NULL and never selects a result — neither does its negation — and only IsNull sees the missing value. An optional field counts as set when its encoding-mask bit is set or when it carries a non-default value; deciding that needs the structure, which is why the result query carries its own evaluator next to the stack's node-oriented FilterEvaluator. Both apply the same NULL rules. An invalid filter is refused with Bad_ContentFilterInvalid, an orderedBy path that names no field with Bad_InvalidArgument.

The query reads the candidates through GetResultIdsAsync(0) and GetResultByIdAsync, so a custom store is filtered and ordered without implementing anything new. The timeout argument of the three getters is the client's hint for how long it will need the data (§6.4); it is not a deadline for the call.

Downloads, and the refused write side

ResultTransferType derives from TemporaryFileTransferType, so the download is the standard OPC 10000-5 sequence: GenerateFileForRead returns a transient FileType object and a handle that is already open, the client reads from it and closes it. MachineryResultTransferManager serves it in the shape SoftwareUpdateFileTransferManager uses for DI software uploads: one transient FileState per handle, the handle bound to the session that opened it, a cap on concurrent handles, and a timeout after which an abandoned handle is reclaimed. Reclaiming is swept at the start of each GenerateFileForRead rather than driven by a per-object timer, so an abandoned handle survives until the next download — where it costs nothing but a transient node.

GenerateFileForWrite and CloseAndCommit are mandatory on every TemporaryFileTransferType (OPC 10000-20 §4.4.1), so a ResultTransfer object always carries them, and they answer. OPC 40001-101 §7.2.1 leaves GenerateFileForWrite unspecialised "as results are supposed to be only generated by the Server, not received", which is the read-only case OPC 10000-20 names a code for: GenerateFileForWrite returns Bad_NotWritable (§4.4.4), and CloseAndCommit returns Bad_InvalidArgument (§4.4.5) for any handle — a download handle included, which stays open and is closed with the file's own Close.

WithResultsFolder publishes the most recent results as ResultType variables as well, which is what the Machinery-Result ResultVariables unit asks for — an empty folder satisfies nothing. The variables are a fixed ring created with the machine: the newest result lands in the first slot and the others shift down, so a client can subscribe to a slot once and keep receiving results, and the server never adds or deletes nodes while it runs.

GetLatestResult, GetResultById and GetResultIdListFiltered each return a ResultHandle that pins what they handed out until the client calls ReleaseResultHandle. The handle is bound to the calling session, and releasing one twice is refused.

Because the payload is materialised from the store synchronously, GenerateFileForRead returns NodeId.Null for completionStateMachine — the value OPC 10000-5 defines for "no completion state machine".

Abstract event types

Two of the event types the series declares are abstract: OPC 40001-101's ResultReadyEventType and OPC 40001-2's ZeroPointAdjustmentEventType. OPC 10000-3 forbids an instance of an abstract type, and a client filtering on a concrete EventType never sees an event reported with an abstract one — so a server that means to publish them has to derive a concrete subtype of its own.

MachineryConcreteEventTypes mints one per node manager into the application-owned instance namespace and every object reporting that event shares it; deriving one per instance would multiply types a client has to know about for no gain. Both the TypeDefinitionId and the EventType field are set, because the second is what an event filter actually selects on. Isa95NodeManager.CreateV2StatusEventTypeAsync does the same for the ISA-95 job-order status event.

The client side mirrors it. An OfType filter on the abstract type still selects the subtype — that is what OfType means — but the generated decoder registry is keyed by the exact event type, so MachineryClient.Events re-decodes against the declared abstract type once the session's type cache confirms the subtype relationship.

State machines are server-driven

Both OPC 40001-1 state machines — MachineryItemState_StateMachineType with Executing / NotExecuting / NotAvailable / OutOfService, and MachineryOperationModeStateMachineType with None / Maintenance / Setup / Processing — declare four states and sixteen transitions each, including the self-transitions. Neither declares a cause method. The whole Machinery NodeSet contains no UAMethod at all, so a client can never request a transition.

The server surface follows: IMachineryItemStateController.SetStateAsync and IMachineryOperationModeController.SetModeAsync, reachable from the machine handle. There is deliberately no WithCause.

await press.ItemState!.SetStateAsync(MachineryItemStateValue.Executing);
await press.OperationMode!.SetModeAsync(MachineryOperationModeValue.Processing);

The state and transition tables live in MachineryStateMachineTables, spelled out from the generated *TypeIds constants — the generator emits these two types as plain FiniteStateMachineState subclasses without the stack's StateTable / TransitionTable / TransitionMappings overrides, so the stack's own SetState would write nothing. The driver still invokes the machine's OnBeforeTransition / OnAfterTransition delegates around the variable update, which is exactly where StateMachineBuilder.For(...) installs its guards and observers, so the repository's state-machine lifecycle surface keeps working.

Client

MachineryClient starts at the Machines folder, so it needs no configured NodeIds and works against a server it has never seen.

Concern API
Discovery EnumerateMachinesAsync, DiscoverMachinesAsync, EnumerateComponentsAsync, EnumerateBuildingBlocksAsync
Identification ReadIdentificationAsync — one TranslateBrowsePaths plus one batched Read
State ItemStateAsync / OperationModeAsync return the generated FSM proxies; GetItemStateAsync / ObserveItemStateAsync wrap them
Health ReadDeviceHealthAsync
Counters ReadOperationCountersAsync, ReadLifetimeCountersAsync
Equipment EnumerateEquipmentAsync — including EquipmentLife
Process values EnumerateProcessValuesAsync, ReadProcessValueAsync, ZeroPointAdjustmentAsync, ObserveZeroPointAdjustmentsAsync
Energy ResolveConsumptionAsync, EnumerateEnergyResourcesAsync, EnumerateMeteringPointsAsync, ReadMainMeteringPointAsync, ReadMeteringPointAsync, and for the sub-meters a point Contains EnumerateSubMetersAsync / ReadSubMetersAsync
Jobs JobManagementAsync returns the ISA-95 Isa95JobControlV2Client; ReadJobOrdersAsync / ReadJobResponsesAsync read the published lists; ReadJobOrderParametersAsync / ReadJobResponseParametersAsync return the predefined OPC 40001-3 parameters typed
Results ResultManagementAsync (whose GetResultIdListFilteredAsync takes the filter and order), DownloadResultAsync, ReadPublishedResultsAsync, ObserveResultsAsync
Notifications ResolveNotificationsAsync, ObserveNotificationsAsync
Device Integration Topology — for a machine that is also a DI device

Process values are found by type definition rather than by browse path: OPC 40001-2 does not fix where they hang — its examples put them on a sensor component, on the machine, and below Monitoring — so every object below the item whose type is ProcessValueType counts.

A metering point's measurements are read with one browse and one batched Read. Each MachineryMeasurementValue carries the NodeId of the variable its reading came from next to the value, so a client that wants live values subscribes to those nodes without browsing the point again:

MachineryMeteringPoint? main = await machinery.ReadMainMeteringPointAsync(resource, ct);
foreach (MachineryMeasurementValue measurement in main!.Measurements)
{
    subscription.TryAddMonitoredItem(
        measurement.BrowseName.Name!,
        measurement.NodeId,
        options => options with { SamplingInterval = TimeSpan.FromSeconds(1) },
        out _);
}

State observation rides the generated *StateMachineTypeClient proxies, which inherit GetCurrentFiniteStateAsync, ObserveFiniteTransitionsAsync and WaitForStateAsync from FiniteStateMachineTypeClient — so a vendor subtype of either state machine is observed the same way. See StateMachines.md.

Constructing the client registers the OPC 40001-101 and OPC 40001-3 structured types, and the ISA-95 Job Control V2 ones the job lists travel as, with the session's encodeable factory, so GetLatestResult decodes into ResultDataType and JobOrderList into ISA95JobOrderAndStateDataType instead of an opaque ExtensionObject.

The predefined job parameters of OPC 40001-3 §7.2 Table 1 travel as ISA-95 key-value pairs rather than as nodes, so the client reads them out of the payload: MachineryJobOrderParameters.FromJobOrder and MachineryJobResponseParameters.FromJobResponse return each one with the type the table declares — Duration in milliseconds, the series' own enumerations as JobExecutionMode / JobResult, and Overproduction from where Table 1 puts it, below PlannedOrderQuantity — and keep every parameter as received in Parameters. A parameter with the wrong type counts as not carried. Optional fields of the generated ISA-95 structures, such as JobOrderParameters and Subparameters, only reach the wire when their EncodingMask bit is set.

Conformance matrix

Status key: ✅ implemented and tested · 📄 static NodeSet structure only · 🔲 optional per the published modelling rule · ❌ not shipped.

Conformance units are advertised at runtime, not statically. The series has two kinds of unit and the manager answers them differently:

  • a type-exposure unit — "the server exposes this type and all its supertypes" — is satisfied by loading the model, so it follows MachineryParts;
  • every other unit talks about instances, methods or references, and is reported only once a build actually materialised and wired the structure behind it.

A server that builds no machine therefore advertises nothing but Machinery Find Machines and whatever types its parts loaded.

OPC 40001-1 building blocks

Area Static Runtime Source Tests
Machines folder organized from Objects (Machinery Find Machines) ✅ ✅ MachineryNodeManager MachineryModelLoadingTests
MachineryBuildingBlocks organizer (Machinery Building Block Organization) ✅ ✅ MachineryItemBlocks MachineryBuildingBlockTests
Machine identification (Machinery Machine Identification) ✅ ✅ MachineryBlockBuilders MachineBuilderTests
Component identification and discovery (Machinery Component Identification, Machinery Find Components of Machines) ✅ ✅ same MachineBuilderTests
Monitoring add-in (Machinery Monitoring) ✅ ✅ same MachineBuilderTests
Monitoring/Health with DI DeviceHealth and DeviceHealthAlarms ✅ ✅ MachineryHealthBuilder MachineBuilderTests; MachineryEndToEndTests
Item state machine, four states / sixteen transitions, no causes (Machinery MachineryItem State) ✅ ✅ MachineryStateMachineTables MachineBuilderTests; MachineryStateMachineTests
Operation-mode state machine (Machinery Operation Mode) ✅ ✅ same same
Operation counters (Machinery Operation Counter) ✅ ✅ MachineryBlockBuilders MachineBuilderTests; MachineryEndToEndTests
Lifetime counters (Machinery Lifetime Counter) ✅ ✅ same MachineBuilderTests
Machinery equipment (Machinery MachineryEquipment) incl. EquipmentLife ✅ ✅ same MachineBuilderTests; MachineryEndToEndTests
Notifications (Machinery Notifications) with a publish seam ✅ ✅ MachineryNotificationsBuilder MachineBuilderTests
Stacklight over OPC 10000-200 IA ✅ 🔲 MonitoringBuilder MachineBuilderTests
Writable identification (Machinery Machine Identification Writable, Component Identification Mandatory, Component Identification Writable) ✅ ✅ opt-in via MachineryIdentificationData.Writable MachineryBlockBuilders MachineryPartsBuilderTests

OPC 40001-2 process values

Area Static Runtime Source Tests
ProcessValueType instances over the ProcessValueVariableType subtype (… Base Types, … Analog Object Instances) ✅ ✅ ProcessValueBuilder MachineryProcessValueTests; MachineryEndToEndTests
Setpoint (… Base SetpointType, … Base Process Value Setpoint) ✅ ✅ same MachineryProcessValueTests
Limits on the signal (… Limits Base) ✅ ✅ same MachineryProcessValueTests
Exclusive limit alarm driven by the value (… Limits Alarm, … Limits Alarm Object) ✅ ✅ MachineryProcessValueAlarm MachineryProcessValueTests
Deviations on the setpoint (… Deviation Base, … Deviation AutoAdjustment, … Deviation Sensitivity) ✅ ✅ ProcessValueBuilder MachineryProcessValueTests
Exclusive deviation alarm against the setpoint (… Deviation Alarm, … Deviation Alarm Object) ✅ ✅ MachineryProcessValueAlarm MachineryProcessValueTests
PercentageValue kept in step with the range (… Percentage Value) ✅ ✅ ProcessValueBuilder MachineryProcessValueTests
Status and AlarmSuppression (… Monitoring, … AlarmSuppression) ✅ ✅ same MachineryProcessValueTests
ZeroPointAdjustment method and its event (… Base EventTypes, … ZeroPointAdjustment Events) ✅ ✅ same MachineryProcessValueTests; MachineryEndToEndTests
Simulation (PA-DIM AnalogSignalVariable Simulation) ✅ ✅ opt-in via WithSimulation() ProcessValueBuilder MachineryProcessValueTests
Device object (… Device Object, … Simple Device Info) ✅ ✅ opt-in via WithProcessValueDevice() MachineBuilder MachineryPartsBuilderTests

OPC 40001-3 job management

Area Static Runtime Source Tests
JobManagement as an AddIn under the organizer, composing ISA-95 Job Control V2 (Machinery Job Management Base, … Minimum String Length) ✅ ✅ JobManagementBuilder MachineryPartsBuilderTests; MachineryEndToEndTests
Job verbs through a machine, including Pause, Resume and RevokeStart and their refusals ✅ ✅ Isa95JobControlV2Binder MachineryEndToEndTests
JobOrderList and JobOrderResponseList kept current, including responses reported while an order runs ✅ ✅ JobManagementBuilder MachineryEndToEndTests
Job results (Machinery Job Management Result Base) ✅ ✅ same MachineryPartsBuilderTests
The predefined Planned … / Result … job-order parameters (42 units) ✅ ✅ opt-in via WithPredefinedParameters() — the 37 IDs of §9 are recognised and their declared types enforced; anything the series does not predefine still travels untouched MachineryJobParameters MachineryPartsBuilderTests

OPC 40001-4 energy

Area Static Runtime Source Tests
Resource folders below Monitoring/Consumption (Machinery Energy Base Structure) ✅ ✅ MachineryEnergyBuilder MachineryPartsBuilderTests; MachineryEndToEndTests
A Main metering point per resource (Machinery Energy Main grouping) ✅ ✅ — checked at build time, so a resource folder attached to Consumption past the builder fails the build instead of breaking the unit same; check in MachineryBuildScope same; MachineryPartsBuilderTests
INonElectricalEnergyType on a metering point (… Non Electrical Base) ✅ ✅ same same
Volume- and mass-flow interfaces (… Volume Flow, … Mass Flow) ✅ ✅ same MachineryPartsBuilderTests
Contains from Main to a sub-meter (Machinery Energy Contains) ✅ ✅ same same; MachineryEndToEndTests
Electricity ✅ ✅ same — OPC 40001-4 defers it to OPC 34100's own interfaces, which WithInterface and AddMeasurementValue attach MachineryEndToEndTests

OPC 40001-101 results

Area Static Runtime Source Tests
Result types (Machinery-Result Types) ✅ ✅ MachineryResultManagementBinder MachineryPartsBuilderTests
The five optional methods (… GetLatestResult, … GetResultById, … GetResultsFiltered, Machinery Result AcknowledgeResults) ✅ ✅ same MachineryPartsBuilderTests
filter, orderedBy and maxResults of GetResultIdListFiltered (§7.1.3), with OPC 10000-4 NULL semantics ✅ ✅ MachineryResultQuery, MachineryResultFilterEvaluator MachineryResultQueryTests; MachineryEndToEndTests
Mandatory GenerateFileForWrite / CloseAndCommit answering for a read-only result file ✅ ✅ MachineryResultTransferManager MachineryResultQueryTests; MachineryEndToEndTests
Publishing through an application-supplied store, writable or self-ingesting — ✅ MachineryResult MachineryResultQueryTests
GenerateFileForRead download path with session-bound handles, cap and timeout (… ResultFiles) ✅ ✅ MachineryResultTransferManager MachineryPartsBuilderTests; MachineryEndToEndTests
Result variables in the Results folder (… ResultVariables) ✅ ✅ MachineryResultVariables MachineryPartsBuilderTests; MachineryEndToEndTests
Predefined result metadata (Machinery-Result PredefinedResultMetaData) ✅ ✅ opt-in via WithPredefinedResultMetaData() — refused on publish and on the way out of a bound store PredefinedResultMetaData MachineryPartsBuilderTests
Result-ready events with a concrete event type (… ResultEvents) ✅ ✅ MachineryResultManagementBinder MachineryPartsBuilderTests; MachineryEndToEndTests
Stand-alone result server without DI or the machine model, reporting the same units and facets ✅ ✅ MachineryResultNodeManager MachineryResultServerTests
Durable result store ❌ not shipped — the storage technology is an open decision; an application plugs its own in through IMachineryResultStore and publishes through it — — —

Client and hosting

Area Static Runtime Source Tests
Discovery, identification, components, building blocks — ✅ MachineryClient MachineryClientTests; MachineryEndToEndTests
State observation over both state machines — ✅ MachineryClient.StateMachines same
Process values, zero-point adjustment — ✅ MachineryClient.ProcessValues MachineryEndToEndTests
Energy resources, metering points and sub-meters — ✅ MachineryClient.Energy same
Counters, equipment, device health — ✅ MachineryClient.Counters same
Job control — ✅ MachineryClient.Accessors same
Job lists and predefined job parameters — ✅ MachineryClient.Jobs, MachineryJobParameters MachineryJobParameterClientTests; MachineryEndToEndTests
Result download, published result variables, result and zero-point events — ✅ MachineryClient.Events same
Server/client DI wiring (AddMachinery, AddMachineryResults, AddMachineryClient) — ✅ OpcUaServerMachineryBuilderExtensions MachineryHostingTests; MachineryAotTests
Facet URIs on ServerProfileArray — ✅ ServerProfiles MachineryEndToEndTests reads them back over the wire

Where the facet URIs come from

Each URI in ServerProfiles is the published value from the "Profile URIs" table of the matching part, at the exact model version this repository vendors. The three URI shapes are the specifications' own and are reproduced verbatim, because conformance tooling matches a facet URI literally:

Part Shape Example
40001-1, -3, -101 …/UA-Profile/Machinery/…, no trailing slash http://opcfoundation.org/UA-Profile/Machinery/Server/State
40001-2 …/UA/Machinery/ProcessValues/…, with a trailing slash http://opcfoundation.org/UA/Machinery/ProcessValues/Server/Base/
40001-4 …/UA-Profile/Machinery/Energy/… http://opcfoundation.org/UA-Profile/Machinery/Energy/Server/Base

A facet is advertised only when every conformance unit the specification marks mandatory for it, and that this library is responsible for, was materialised and wired. The base-server units each facet also inherits — address space, view, attribute, method and event subscription — are the stack's to report and already appear in base.ServerProfiles.

Model sources, identifier tables and repairs

Every NodeSet is vendored unmodified from OPCFoundation/UA-Nodeset (branch latest). Two files are renamed on the way in, because upstream's names do not match the assembly they belong to; renaming is safe because NodeSetToModelDesign.IsNodeSet inspects the document, not the file name.

Repository path Upstream path
src/Opc.Ua.Machinery/Model/Opc.Ua.Machinery.NodeSet2.xml Machinery/Opc.Ua.Machinery.NodeSet2.xml
src/Opc.Ua.Machinery.ProcessValues/Model/Opc.Ua.Machinery.ProcessValues.NodeSet2.xml Machinery/ProcessValues/Opc.Ua.Machinery.ProcessValues.NodeSet2.xml
src/Opc.Ua.Machinery.Jobs/Model/Opc.Ua.Machinery.Jobs.NodeSet2.xml Machinery/Jobs/Opc.Ua.Machinery.Jobs.Nodeset2.xml (lower-case s)
src/Opc.Ua.Machinery.Energy/Model/Opc.Ua.Machinery.Energy.NodeSet2.xml Machinery/Energy/Opc.Ua.Machinery.Energy.NodeSet2.xml
src/Opc.Ua.Machinery.Result/Model/Opc.Ua.Machinery.Result.NodeSet2.xml Machinery/Result/Opc.Ua.Machinery_Result.NodeSet2.xml (underscore)
src/Opc.Ua.IA/Model/Opc.Ua.IA.NodeSet2.xml IA/Opc.Ua.IA.NodeSet2.xml
src/Opc.Ua.ECM/Model/Opc.Ua.ECM.NodeSet2.xml ECM/Opc.Ua.ECM.NodeSet2.xml
src/Opc.Ua.PADIM/Model/Opc.Ua.PADIM.NodeSet2.xml PADIM/Opc.Ua.PADIM.NodeSet2.xml
src/Opc.Ua.PADIM/Model/Opc.Ua.IRDI.NodeSet2.xml PADIM/Opc.Ua.IRDI.NodeSet2.xml

What the identifier table is and is not

Each model carries a Model/*.NodeIds.csv wired up as ModelSourceGeneratorIdentifierFile. It is not a NodeId source — for a NodeSet2 input the NodeIds come from the XML (ModelDesignValidator.LoadDesignFile routes NodeSet2 documents through NodeSetToModelDesign.Import; only ModelDesign inputs use the CSV fallback). It is a drift guard: the generator validates every row against the imported symbols and fails the build with MODELGEN022…MODELGEN029 when the vendored model and the pinned mapping disagree. That is how a silently dropped type gets caught instead of quietly disappearing from the generated API.

Model/*.Upstream.NodeIds.csv is the OPC Foundation's publication unmodified and is deliberately kept out of <AdditionalFiles>, so the two can be diffed to review exactly what the derivation changed — the convention src/Opc.Ua.ISA95 established.

Why the published tables need deriving

The published tables carry the UA-ModelCompiler's symbolic names, which differ from the ones this repository's generator derives. Three differences occur; all three are handled by tools/nodesets/derive-identifier-table.py, which reimplements NodeSetToModelDesign.ImportSymbolicName / BuildSymbolicId:

Published Derived Why
Server_Namespaces_http___…_ http___…_ the publication qualifies the namespace-metadata object with its server path
X_ControlChannel X_ControlChannel_Placeholder a <Placeholder> BrowseName maps to Name_Placeholder
DefaultBinary RGBWDataType_Encoding_DefaultBinary encoding nodes are qualified with the data type they encode

Two publications also deviate in shape rather than in naming, and the script reads both: ECM publishes its table as Opc.Ua.ECM.NodeSet2.csv rather than *.NodeIds.csv, and PADIM publishes a tab-separated table with an ID / Browsename / Node Class header — identifier first, and BrowseNames rather than symbolic names. IRDI has no published table at all and therefore carries no drift guard.

No numeric id or node class disagreed with its NodeSet in any of the nine models, so no NodeSet needed a normative repair of the kind docs/ISA95.md documents for OPC-10030.

Generator gaps found while adding these models

Three defects in the cross-assembly dependency machinery only surfaced once a model consumed a dependency through a referenced assembly's payload rather than through <AdditionalFiles>. All three are now fixed in the generator. They are worth knowing about when adding the next companion specification.

A VariableType's data type restriction was not carried. DependencyNode recorded base type, numeric id, abstractness, data type fields and children, but never a VariableType's own DataType / ValueRank. A consumer that typed a variable with such a VariableType resolved a null DataTypeNode, and ModelDesignExtensions.GetNodeStateClassName dereferenced it — a bare NullReferenceException surfaced as MODELGEN003. OPC 40001-1's EquipmentLife and <LifetimeVariable>, typed by DI's LifetimeVariableType, are the first nodes in this repository to hit it. This is what the reduced Machinery NodeSet copies in the samples were working around.

The payload now carries the restriction behind a new node flag, so payloads written before the change stay readable, and GetNodeStateClassName throws a message naming the variable and its type rather than a bare null dereference if a gap ever remains.

A method declaration was not chained to its method state. When a payload child carried both a method-state identity and a declaration identity, the consumer replaced the method-state node with the declaration node without linking the two. ResolveMethodStateIdentity then stopped at the declaration — whose symbolic id is the composed OwnerType_Method — and a consumer that re-declares an inherited method emitted a reference to an OwnerType_MethodMethodState class the producer never generated. OPC 34100 ECM re-declaring the DI LockingServices methods is the first model to hit it.

An inherited child lost its reference type and its browse-name namespace. DependencyChild carried the child's browse name as a bare string and no reference type, so when a derived type in a consuming model was instantiated on its own, the generator emitted the inherited child with ReferenceTypeId = NodeId.Null and the browse name qualified with the declaring model's namespace. Both were wrong, and the first was not cosmetic: a node with a null reference type is in the node tree but produces no reference in a filtered Browse, so no client ever saw it.

Two OPC 40001-2 members were affected, both inherited from OPC 30081 PA-DIM:

Member Reference type Browse name
ProcessValueType.SignalTag lost correct (3:SignalTag)
ProcessValueVariableType.EngineeringUnits lost wrong (PA-DIM, should be namespace 0)

The same member reached the other way — ProcessValueType.AnalogSignal's EngineeringUnits, generated as a grandchild of the object type — was correct, which is what made the inconsistency visible. SignalTag is mandatory, and EngineeringUnits in namespace 0 is where a Data Access client reads the unit, so both matter for the OPC 40001-2 base facet.

DependencyChild now carries a BrowseNameNamespace and a ReferenceTypeName / ReferenceTypeNamespace, behind two flag bits in the per-child flag byte, the way the VariableType restriction was added — so a payload written before the change stays readable. The producer records a browse namespace only when it differs from the declaring model and a reference type only when it departs from the kind default, which keeps the payload byte-identical for the ordinary case. The consumer (MaterialiseDependencyChild) now always assigns a reference type: the carried one when present, otherwise the same kind default ImportInstance applies to a locally declared instance. That last part matters — it means a model still consuming an older prebuilt payload is fixed too, not only one rebuilt against the new generator.

The former stop-gap ProcessValueBuilder.RepairInheritedPadimMembers is gone; what remains of it is ApplyDefaultSignalTag, which was never a workaround but the OPC 30081 default value for a mandatory tag. Only the in-process tests missed the defect; the end-to-end test over opc.tcp is what surfaced it, because a filtered Browse is where a null reference type finally shows, and MachineryProcessValueTests .TheSignalKeepsThePadimBrowseNameAndTheNarrowerTypeAsync pins all four attributes.

See also