/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.OpenUsd;
using Opc.Ua.OpenUsd.Server;
using Opc.Ua.Pumps;
namespace Pumps
{
///
/// Wires the draft OPC UA — OpenUSD Bindings companion model onto the Pumps
/// server: the well-known OpenUSD facility (stage + representation registries),
/// a PlantStage descriptor, and an OpenUsdRepresentation AddIn on Pump #1 with
/// three read-only live bindings (Part 2, UaToUsdTelemetry).
///
public partial class PumpNodeManager
{
private OpenUsdRootState? m_openUsdRoot;
private OpenUsdStageState? m_plantStage;
///
/// Everything the OpenUSD twin needs to render one simulated pump: the
/// prim the pump composes into, the hand-built signal Variables that
/// feed its bindings, and the simulation those signals read. One
/// instance exists per configured pump, so a server started with
/// --pumps N drives N independently animated machines.
///
private sealed class PumpTwin
{
public PumpTwin(string primPath, int pumpNumber)
{
PrimPath = primPath;
PumpNumber = pumpNumber;
}
///
/// Absolute prim path of this pump on the plant stage.
///
public string PrimPath { get; }
///
/// One-based number of the simulated unit.
///
public int PumpNumber { get; }
public BaseDataVariableState? AlarmActive { get; set; }
public BaseDataVariableState? ShaftAngle { get; set; }
public BaseDataVariableState? SpeedSetpoint { get; set; }
public BaseDataVariableState? BayPosition { get; set; }
///
/// Position of the liquid surface in the suction vessel, published as a
/// structured coordinate so the Translation profile can drive it.
///
public BaseDataVariableState? FluidSurface { get; set; }
public OpenUsdRepresentationState? Representation { get; set; }
///
/// The pump this twin renders, needed to resolve nodes the fluent pass
/// creates after the pump has been materialised.
///
public PumpState? Pump { get; set; }
///
/// The simulation this twin renders. Assigned by
/// RegisterPumpSimulation, which runs after the pump has been
/// materialised, so it stays null until the fluent pass completes.
///
public PumpSimulationState? Simulation { get; set; }
///
/// Shaft angular position in degrees, or zero before the simulation
/// is wired.
///
public double ShaftAngleDegrees => Simulation?.ShaftAngleDegrees ?? 0.0;
///
/// The supervision alarm state the status-light binding follows.
///
public bool AlarmActiveState => Simulation?.AlarmActive ?? false;
}
// Written while the address space is built and while a pump is added at
// runtime; read by the 250 ms simulation tick.
private readonly ConcurrentDictionary m_twins = new();
///
/// Rate at which the server samples the hand-built OpenUSD signals, in
/// milliseconds. Matches the simulation tick so every change is observed.
///
private const double SignalSamplingIntervalMilliseconds = 250;
///
/// Registers the Variables that are hand-built onto the pump rather than
/// materialised by the generated factory. A generated instance state only
/// reports its declared members, so these children are never picked up by
/// the walk that AddPredefinedNodeAsync(pump) performs: they browse
/// and read correctly but are never sampled, which leaves any binding that
/// uses them frozen at its start-up value.
///
/// The pump whose signals to register.
/// Cancellation token.
///
/// Registers one pump, not every pump materialised so far. This runs once
/// per pump as it is created, and the twin is already in the dictionary by
/// then, so walking the whole dictionary would re-register every earlier
/// pump and add its shaft angle to the publish set again - leaving the
/// first pump published N times per tick for N pumps.
///
private async ValueTask RegisterOpenUsdSignalsAsync(
PumpState pump, CancellationToken cancellationToken)
{
if (!m_twins.TryGetValue(pump.NodeId, out PumpTwin? twin))
{
return;
}
foreach (BaseDataVariableState? signal in new[]
{
twin.AlarmActive, twin.ShaftAngle, twin.SpeedSetpoint,
twin.BayPosition, twin.FluidSurface
})
{
if (signal != null)
{
await AddPredefinedNodeAsync(SystemContext, signal, cancellationToken)
.ConfigureAwait(false);
}
}
// Push the shaft angle from the same loop that drives the generated
// measurement Variables, so it uses the one path proven to raise
// data-change notifications. The closure captures this pump's twin,
// so every pump reports its own angle.
if (twin.ShaftAngle != null)
{
TrackSignal(twin.ShaftAngle, () => twin.ShaftAngleDegrees);
}
}
private const string PlantRootLayerIdentifier = "asset-repo/Plant.usd";
///
/// Prim scope the configured pumps are composed into. Each pump owns
/// <PumpsScopePrimPath>/<BrowseName>, which the plant
/// aggregation composes from the served pump.usda component asset.
///
private const string PumpsScopePrimPath = "/Plant/Pumps";
///
/// Plant prim the DeviceSet-level representation anchors on.
///
private const string PlantPrimPath = "/Plant";
///
/// Spacing between two pump bays along the plant Y axis, in metres. Wide
/// enough to clear the 1.80 m baseplate with an access aisle.
///
private const double PumpBaySpacingMetres = 2.4;
///
/// Absolute prim path of the supplied pump.
///
private static string PumpPrimPathFor(PumpState pump)
{
return PumpsScopePrimPath + "/" + (pump.BrowseName.Name ?? "Pump");
}
private async ValueTask MaterialiseOpenUsdFacilityAsync(
CancellationToken cancellationToken)
{
try
{
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
OpenUsdRootState root = SystemContext.CreateInstanceOfOpenUsdRootType(
null!, new QualifiedName("OpenUSD", ns));
root.NodeId = new NodeId("OpenUSD", ns);
FolderState stages = root.Stages
?? root.CreateOrReplaceStages(SystemContext, null!);
_ = root.Representations
?? root.CreateOrReplaceRepresentations(SystemContext, null!);
m_plantStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("PlantStage", ns));
stages.AddChild(m_plantStage);
m_plantStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = PlantRootLayerIdentifier;
// §5.2 Twin-BOM content integrity: the digest is computed over the
// *resolved root-layer content*, never over the identifier string, so a
// connector can detect tampering of the bytes it actually composes.
List servedAssets = LoadServedAssets();
byte[] rootLayerBytes = servedAssets
.Find(a => a.Kind == OpenUsdAssetKindEnum.RootLayer)!.Bytes;
byte[] digest;
#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility)
using (var sha = System.Security.Cryptography.SHA256.Create())
{
digest = sha.ComputeHash(rootLayerBytes);
}
#pragma warning restore CA1850
m_plantStage.CreateOrReplaceRootLayerDigest(
SystemContext,
SystemContext.CreateOpenUsdStageType_RootLayerDigest(m_plantStage, forInstance: true))
.Value = (ByteString)digest;
m_plantStage.CreateOrReplaceRootLayerDigestAlgorithm(
SystemContext,
SystemContext.CreateOpenUsdStageType_RootLayerDigestAlgorithm(m_plantStage, forInstance: true))
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
// Per the companion spec §4.2 the OpenUSD facility SHALL be a
// component of the Server Object (i=2253) so that any conformant
// connector can Browse Server -> OpenUSD -> Representations. Record
// the inverse reference here; the matching forward reference from
// the Server Object (owned by the core node manager) is added via
// the externalReferences dictionary in LinkOpenUsdRootToServer.
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
// §5.15 asset content delivery (OU-AssetDelivery): serve this stage's
// artist-authored USD layer closure so a connector can render the twin
// with no external asset resolver.
UsdAssetDelivery.AttachStageAssets(SystemContext, m_plantStage, ns, servedAssets);
SystemContext.AssignInstanceChildNodeIds(root);
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
.ConfigureAwait(false);
m_openUsdRoot = root;
m_logger.MaterialisedOpenUsdFacility(root.NodeId, m_plantStage.NodeId);
}
catch (Exception ex)
{
m_plantStage = null;
m_openUsdRoot = null;
m_logger.LogError(ex, "Failed to materialise the OpenUSD facility.");
}
}
// Loads the embedded artist-authored USD layers this server serves (spec §5.15).
private static List LoadServedAssets()
{
return new List
{
new ServedAsset("Plant.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Plant.usda")),
new ServedAsset("pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("pump.usda")),
};
}
private static byte[] ReadEmbeddedAsset(string resourceName)
{
using Stream? s = typeof(PumpNodeManager).Assembly.GetManifestResourceStream(resourceName);
if (s == null)
{
return Array.Empty();
}
using var ms = new MemoryStream();
s.CopyTo(ms);
return ms.ToArray();
}
///
public override async ValueTask CreateAddressSpaceAsync(
IDictionary> externalReferences,
CancellationToken cancellationToken = default)
{
// base.CreateAddressSpaceAsync loads predefined nodes and runs
// ConfigureAsync, which materialises the OpenUSD facility.
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
// Now that the root exists, add the forward HasComponent reference from
// the Server Object (i=2253, owned by the core node manager) to it.
LinkOpenUsdRootToServer(externalReferences);
}
// Adds the Server -> OpenUSD forward reference into the externalReferences
// dictionary the master node manager applies across managers, so the
// well-known facility is browsable from the Server Object (spec §4.2).
private void LinkOpenUsdRootToServer(
IDictionary> externalReferences)
{
if (m_openUsdRoot == null)
{
return;
}
if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.Server, out IList? references)
|| references == null)
{
externalReferences[Opc.Ua.ObjectIds.Server] = references = new List();
}
references.Add(new NodeStateReference(
ReferenceTypeIds.HasComponent, false, m_openUsdRoot.NodeId));
m_logger.LogInformation(
"Linked OpenUSD facility under the Server Object (i=2253).");
}
// Call before AddPredefinedNodeAsync(pump), so the binding source
// NodeIds captured here are the per-instance ones the generated
// CreateOrReplace/AddXxx helpers assigned.
private void AttachOpenUsdRepresentation(PumpState pump, int pumpNumber)
{
if (m_plantStage == null)
{
return;
}
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
string primPath = PumpPrimPathFor(pump);
var twin = new PumpTwin(primPath, pumpNumber);
OpenUsdRepresentationState rep = SystemContext.CreateRepresentation(
pump, m_plantStage.NodeId, primPath, ns);
twin.Representation = rep;
twin.Pump = pump;
MeasurementsState? m = pump.Operational?.Measurements;
NodeId bearingTemp = m?.BearingTemperature?.NodeId ?? NodeId.Null;
NodeId diffPressure = m?.DifferentialPressure?.NodeId ?? NodeId.Null;
NodeId fluidTemp = m?.FluidTemperature?.NodeId ?? NodeId.Null;
NodeId massFlow = m?.MassFlow?.NodeId ?? NodeId.Null;
NodeId efficiency = m?.PumpEfficiency?.NodeId ?? NodeId.Null;
NodeId level = m?.Level?.NodeId ?? NodeId.Null;
NodeId numberOfStarts = m?.NumberOfStarts?.NodeId ?? NodeId.Null;
// Layout: each pump publishes the bay it stands in, bound as a
// translation on its own prim. That keeps the shared component asset
// position-free, so any number of pumps lays out without editing it.
twin.BayPosition = CreatePumpVariable(
pump,
"BayPosition",
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
Variant.From(new ExtensionObject(new ThreeDCartesianCoordinates
{
X = 0.0,
Y = (pumpNumber - 1) * PumpBaySpacingMetres,
Z = 0.0
})),
writable: false);
CreateBinding(rep, ns, "BayLayout",
GuidFor("BayLayout"),
twin.BayPosition.NodeId, primPath, "xformOp:translate", "double3",
OpenUsdRenderTargetKindEnum.Translation, 1.0);
// 0.2 UaAlarmToUsd: a supervision alarm active-state drives the status
// light visibility. A dedicated Boolean variable exposes the alarm
// aspect the simulation toggles (see AdvanceSimulation).
twin.AlarmActive = CreatePumpVariable(
pump, "AlarmActive", Opc.Ua.DataTypeIds.Boolean, new Variant(false), writable: false);
// Serve the live flag from the simulation. The Variable is registered
// in its own right by RegisterOpenUsdSignalsAsync, which is what lets
// a monitored item sample it.
twin.AlarmActive.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
PumpTwin alarmTwin = twin;
twin.AlarmActive.OnSimpleReadValue =
(ISystemContext context, NodeState node, ref Variant value) =>
{
value = new Variant(alarmTwin.AlarmActiveState);
return ServiceResult.Good;
};
// Shaft angular position. This is what makes the twin *look* like it
// is running: MassFlow is a rate, so binding it straight to a rotation
// op only ever produces a fixed fraction of a degree. Integrating it
// into an angle gives the impeller and coupling a continuous spin.
twin.ShaftAngle = CreatePumpVariable(
pump, "ShaftAngle", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: false);
twin.ShaftAngle.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
PumpTwin shaftTwin = twin;
twin.ShaftAngle.OnSimpleReadValue =
(ISystemContext context, NodeState node, ref Variant value) =>
{
value = new Variant(shaftTwin.ShaftAngleDegrees);
return ServiceResult.Good;
};
// Shaft position -> impeller rotation. MassFlow is a *rate*, so binding
// it straight to a rotation op pins the shaft at a fraction of a degree;
// the simulation integrates it into an angle instead. The scale slows
// the render to a legible ~45 degrees per second - a real 2900 rpm shaft
// would alias into a stroboscopic blur at any practical sampling rate.
CreateBinding(rep, ns, "ShaftSpin",
GuidFor("ShaftSpin"),
twin.ShaftAngle.NodeId, primPath + "/Impeller", "xformOp:rotateZ", "double",
OpenUsdRenderTargetKindEnum.Rotation, ShaftRenderScale,
sourceSemanticId: MassFlowSemanticId);
// The motor cooling fan turns with the duty point, so a pump that is
// barely moving fluid is visibly loafing.
CreateBinding(rep, ns, "MotorFanSpin",
GuidFor("MotorFanSpin"),
twin.ShaftAngle.NodeId, primPath + "/Motor/FanBlades", "xformOp:rotateZ", "double",
OpenUsdRenderTargetKindEnum.Rotation, ShaftRenderScale * FanToShaftRatio);
// BearingTemperature is published in Kelvin (OPC 40223), but the
// DisplayColor render target ramps blue -> red over the datasheet
// bearing-temperature range, so the binding declares the Kelvin shift.
CreateBinding(rep, ns, "BearingTempColor",
GuidFor("BearingTempColor"),
bearingTemp, primPath + "/Body", "primvars:displayColor", "color3f[]",
OpenUsdRenderTargetKindEnum.DisplayColor, 1.0,
offset: -KelvinOffset);
// Bearing-temperature gauge: 0 degrees at the bottom of the datasheet
// range, sweeping SweepDegrees over the whole range.
CreateBinding(rep, ns, "BearingTempNeedle",
GuidFor("BearingTempNeedle"),
bearingTemp, primPath + "/PowerEnd/TempGauge/Needle", "xformOp:rotateZ", "double",
OpenUsdRenderTargetKindEnum.Rotation,
GaugeSweepDegrees / (PumpDatasheet.Ranges.BearingTemperatureMax -
PumpDatasheet.Ranges.BearingTemperatureMin),
offset: -GaugeSweepDegrees * PumpDatasheet.Ranges.BearingTemperatureMin /
(PumpDatasheet.Ranges.BearingTemperatureMax -
PumpDatasheet.Ranges.BearingTemperatureMin));
// Discharge pressure gauge needle over the datasheet pressure range.
CreateBinding(rep, ns, "DischargePressureNeedle",
GuidFor("DischargePressureNeedle"),
diffPressure, primPath + "/Discharge/Gauge/Needle", "xformOp:rotateZ", "double",
OpenUsdRenderTargetKindEnum.Rotation,
GaugeSweepDegrees / PumpDatasheet.Ranges.DifferentialPressureMax);
// Suction vessel: the connector converts a scalar level into a scalar,
// and xformOp:translate needs a double3, so the simulation publishes the
// surface position as a structured coordinate the Translation profile
// accepts. The surface disc rides on top of the liquid.
twin.FluidSurface = CreatePumpVariable(
pump,
"FluidSurfacePosition",
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
Variant.From(new ExtensionObject(FluidSurfaceAt(
PumpDatasheet.Simulation.LevelNominal))),
writable: false);
twin.FluidSurface.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds;
CreateBinding(rep, ns, "SuctionLevelRise",
GuidFor("SuctionLevelRise"),
twin.FluidSurface.NodeId, primPath + "/SuctionVessel/Surface",
"xformOp:translate", "double3",
OpenUsdRenderTargetKindEnum.Translation, 1.0);
// Efficiency and mass flow carry no render semantics of their own -- the
// DisplayColor ramp models a temperature, so colouring efficiency with it
// would read as a lie. They are surfaced as attributes a viewer shows on
// selection, which is what makes the twin inspectable.
CreateBinding(rep, ns, "EfficiencyReadout",
GuidFor("EfficiencyReadout"),
efficiency, primPath + "/Motor/Nameplate", "inputs:pumpEfficiency", "double",
OpenUsdRenderTargetKindEnum.Custom, 1.0);
// Suction line tint follows the pumped fluid temperature. The
// DisplayColor ramp runs blue -> red over 20..100 degrees Celsius, so
// the binding declares the Kelvin shift and nothing else.
CreateBinding(rep, ns, "FluidTempColor",
GuidFor("FluidTempColor"),
fluidTemp, primPath + "/Suction/Neck/Mat/Surface", "inputs:diffuseColor", "color3f",
OpenUsdRenderTargetKindEnum.DisplayColor, 1.0,
offset: -KelvinOffset);
CreateBinding(rep, ns, "MassFlowReadout",
GuidFor("MassFlowReadout"),
massFlow, primPath + "/Motor/Nameplate", "inputs:massFlow", "double",
OpenUsdRenderTargetKindEnum.Custom, 1.0,
sourceSemanticId: MassFlowSemanticId);
CreateBinding(rep, ns, "NumberOfStartsReadout",
GuidFor("NumberOfStartsReadout"),
numberOfStarts, primPath + "/Motor/Nameplate", "inputs:numberOfStarts", "double",
OpenUsdRenderTargetKindEnum.Custom, 1.0);
AttachAlarmBindings(pump, rep, ns, primPath, twin);
// 0.2 UsdToUaCommand (opt-in): a writable speed setpoint Variable is the
// command target. The binding is Controllable and present, but a
// connector only issues the write when explicitly enabled AND authorized
// (single-writer, fail-closed). Enabled=true means "declared", NOT
// "auto-actuated" — the opt-in lives on the connector, not on Enabled.
// §5.10/§9 authorization posture: the command target is *capable* of being
// written (AccessLevel), but the write right is withheld by default and
// granted only to an authenticated (non-anonymous) session — a Server
// "withholds by default" the RolePermissions a connector must hold before
// issuing any command.
twin.SpeedSetpoint = CreatePumpVariable(
pump, "SpeedSetpoint", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: true);
twin.SpeedSetpoint.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel;
CreateBinding(rep, ns, "SpeedSetpointCommand",
GuidFor("SpeedSetpointCommand"),
NodeId.Null, primPath + "/Impeller", "inputs:speedSetpoint", "double",
kind: null, 1.0,
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdCommandBindingType,
signalRole: OpenUsdSignalRoleEnum.Controllable,
commandTargetNodeId: twin.SpeedSetpoint.NodeId,
commandTriggerPropertyName: "inputs:speedSetpoint");
// Composition (§5.12): the pump is composed of an Impeller and a Bearing,
// each a component Object with its own representation, mapped 1:1 to a child
// prim (arc=Child). This adds bindings on the pump representation.
AttachPumpComponents(pump, rep, ns, primPath);
SystemContext.AssignInstanceChildNodeIds(rep);
m_twins[pump.NodeId] = twin;
}
///
/// Binds the per-pump supervision states, so the twin distinguishes
/// which fault a pump has - and which pump has it - rather than
/// showing one undifferentiated halo.
///
private void AttachAlarmBindings(
PumpState pump,
OpenUsdRepresentationState rep,
ushort ns,
string primPath,
PumpTwin twin)
{
SupervisionState? events = pump.Events;
NodeId cavitation = events?.SupervisionProcessFluid?.Cavitation?.NodeId ?? NodeId.Null;
NodeId motorOverheat = events?.SupervisionPumpOperation?.MotorOverheat?.NodeId ?? NodeId.Null;
// Any active supervision state draws a red circle on the floor around
// the machine. A ring reads from anywhere in the hall and from any
// camera angle; a lamp on a mast only reads when you happen to be
// looking straight at it.
CreateBinding(rep, ns, "AlarmRingVisibility",
GuidFor("AlarmRingVisibility"),
twin.AlarmActive!.NodeId, primPath + "/AlarmRing", "visibility", "token",
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
// Which fault, at the place on the machine where it actually is: the
// ring says a pump is in alarm, these say why.
CreateBinding(rep, ns, "CavitationHalo",
GuidFor("CavitationHalo"),
cavitation, primPath + "/Suction/CavitationHalo", "visibility", "token",
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
CreateBinding(rep, ns, "OverheatHalo",
GuidFor("OverheatHalo"),
motorOverheat, primPath + "/OverheatHalo", "visibility", "token",
OpenUsdRenderTargetKindEnum.Visibility, 1.0,
bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType,
alarmAspect: OpenUsdAlarmAspectEnum.ActiveState);
// The OverTempAlarm condition itself is deliberately not bound. The
// fluent alarm builder leaves the condition's state children on their
// standard namespace-0 declaration NodeIds - which
// PumpInstanceNodeIdRegressionTests pins - so every pump's alarm shares
// one ActiveState, Severity and AckedState node. Binding those would
// ring every pump in the hall at once. The per-pump supervision states
// above are the alarm indication instead: they are genuinely per
// instance, and they are what drives the condition through
// ActivatesAlarm in the first place.
}
///
/// Position of the suction-vessel liquid surface for a published level.
///
private static ThreeDCartesianCoordinates FluidSurfaceAt(double levelMetres)
{
return new ThreeDCartesianCoordinates
{
X = 0.0,
Y = 0.0,
Z = levelMetres * FluidSurfaceScale
};
}
///
/// Stable declaration identifiers for the pump bindings. The binding model
/// defines the effective runtime identity as
/// (represented object, BindingDefinitionId) and the id itself as a
/// declaration identifier, "NOT a runtime instance key" — so every pump
/// declares the same ids and its own representation disambiguates them.
///
private static readonly Dictionary s_bindingDefinitionIds =
new(StringComparer.Ordinal)
{
["ShaftSpin"] = new Guid("6e63cf2c-f2de-4f78-a8f8-f0ccdbb7647a"),
["BearingTempColor"] = new Guid("b1a1f6f0-5c2b-5a1e-9f3a-2b7c4d8e0011"),
["AlarmRingVisibility"] = new Guid("d3c3b8f2-7e4d-5c30-b15c-4d9e6a0b2233"),
["SpeedSetpointCommand"] = new Guid("e4d4c9a3-8f5e-5d41-c26d-5e0f7b1c3344"),
["ImpellerComponent"] = new Guid("a1b2c3d4-0001-4000-8000-000000000001"),
["BearingComponent"] = new Guid("a1b2c3d4-0001-4000-8000-000000000002"),
["BayLayout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000001"),
["MotorFanSpin"] = new Guid("a1b2c3d4-0005-4000-8000-000000000002"),
["BearingTempNeedle"] = new Guid("a1b2c3d4-0005-4000-8000-000000000003"),
["DischargePressureNeedle"] = new Guid("a1b2c3d4-0005-4000-8000-000000000004"),
["SuctionLevelRise"] = new Guid("a1b2c3d4-0005-4000-8000-000000000005"),
["EfficiencyReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000006"),
["FluidTempColor"] = new Guid("a1b2c3d4-0005-4000-8000-000000000007"),
["MassFlowReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000008"),
["NumberOfStartsReadout"] = new Guid("a1b2c3d4-0005-4000-8000-000000000009"),
["CavitationHalo"] = new Guid("a1b2c3d4-0005-4000-8000-00000000000a"),
["OverheatHalo"] = new Guid("a1b2c3d4-0005-4000-8000-00000000000b")
};
///
/// The declaration identifier of the named binding.
///
private static Guid GuidFor(string binding)
{
return s_bindingDefinitionIds[binding];
}
///
/// Renders the integrated shaft angle at a legible speed: a real 2900 rpm
/// shaft aliases into a stroboscopic blur at any practical sampling rate.
///
private const double ShaftRenderScale = 0.0025;
///
/// The cooling fan sits on the same shaft, so it turns at the same speed;
/// rendering it slightly faster keeps the two visually distinguishable.
///
private const double FanToShaftRatio = 1.6;
///
/// Angular sweep of a gauge needle across its full scale, in degrees.
///
private const double GaugeSweepDegrees = 270.0;
///
/// Kelvin-to-Celsius shift the colour render targets expect.
///
private const double KelvinOffset = 273.15;
///
/// Metres of modelled vessel height per metre of published level. The
/// suction vessel is drawn at a fifth of its real height so it does not
/// tower over the machine it feeds.
///
private const double FluidSurfaceScale = 0.2;
// ECLASS-style IRDI for "volume flow rate" — a portable semantic id a
// connector can use to resolve the source across vendors (0.2 SemanticSource).
private const string MassFlowSemanticId = "0173-1#02-AAO677#002";
///
/// §5.10/§9: the write right a command target requires is withheld by
/// default — an anonymous session sees a read-only UserAccessLevel and its
/// write is rejected. Only a session that authenticated (and therefore holds a
/// Role beyond Anonymous) sees CurrentWrite.
///
private static ServiceResult OnReadCommandTargetUserAccessLevel(
ISystemContext context, NodeState node, ref byte value)
{
value = IsAuthenticatedSession(context)
? AccessLevels.CurrentReadOrWrite
: AccessLevels.CurrentRead;
return ServiceResult.Good;
}
private static bool IsAuthenticatedSession(ISystemContext context)
{
IUserIdentity? identity = (context as ISessionSystemContext)?.UserIdentity;
if (identity == null || identity.TokenType == UserTokenType.Anonymous)
{
return false;
}
ArrayOf roles = identity.GrantedRoleIds;
for (int i = 0; i < roles.Count; i++)
{
if (roles[i] != Opc.Ua.ObjectIds.WellKnownRole_Anonymous)
{
return true;
}
}
return false;
}
// Creates a simple Variable child on the pump (used for the 0.2 command
// setpoint and alarm-active demo signals), assigning a per-instance NodeId
// immediately: the node is hand-built, so no generated helper assigns one.
private BaseDataVariableState CreatePumpVariable(
PumpState pump, string name, NodeId dataType, Variant initialValue, bool writable)
{
byte access = writable
? AccessLevels.CurrentReadOrWrite
: AccessLevels.CurrentRead;
var v = new BaseDataVariableState(pump)
{
SymbolicName = name,
BrowseName = new QualifiedName(name, pump.BrowseName.NamespaceIndex),
DisplayName = new LocalizedText(name),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
DataType = dataType,
ValueRank = ValueRanks.Scalar,
AccessLevel = access,
UserAccessLevel = access,
Value = initialValue,
};
pump.AddChild(v);
v.NodeId = SystemContext.NodeIdFactory.New(SystemContext, v);
return v;
}
///
/// Registers every pump's representation in the well-known
/// Server/OpenUSD/Representations registry. A connector discovers
/// twins through that registry alone, so a representation that is not
/// organised there is invisible no matter how completely it is authored.
///
private void OrganiseRepresentations()
{
FolderState? registry = m_openUsdRoot?.Representations;
if (registry == null)
{
return;
}
foreach (PumpTwin twin in m_twins.Values)
{
OpenUsdRepresentationState? rep = twin.Representation;
if (rep == null)
{
continue;
}
registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId);
rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId);
}
}
// Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: binds the
// plant stage and forwards. The binding-authoring logic lives in the SDK
// (OpenUsdRepresentationAuthoring.AddLiveBinding), not in this sample.
private void CreateBinding(
OpenUsdRepresentationState rep, ushort ns, string name,
Guid bindingDefinitionId, NodeId sourceNodeId, string targetPrimPath,
string targetPropertyName, string targetUsdTypeName,
OpenUsdRenderTargetKindEnum? kind, double scale,
uint bindingTypeId = Opc.Ua.OpenUsd.ObjectTypes.OpenUsdValueChangeBindingType,
OpenUsdSignalRoleEnum signalRole = OpenUsdSignalRoleEnum.Observable,
string? sourceSemanticId = null,
OpenUsdAlarmAspectEnum? alarmAspect = null,
NodeId commandTargetNodeId = default,
string? commandTriggerPropertyName = null,
double offset = 0.0)
{
OpenUsdLiveBindingState binding = rep.AddLiveBinding(
SystemContext, ns, m_plantStage!.NodeId, name, bindingDefinitionId, sourceNodeId,
targetPrimPath, targetPropertyName, targetUsdTypeName, kind, scale,
bindingTypeId, signalRole, sourceSemanticId, alarmAspect,
commandTargetNodeId, commandTriggerPropertyName);
// 5.8 applies Scale then Offset. AddLiveBinding only sets Scale, so an
// additive term (such as the Kelvin -> Celsius shift the DisplayColor
// render target expects) has to be authored here.
if (offset != 0.0)
{
binding.CreateOrReplaceOffset(
SystemContext,
SystemContext.CreateOpenUsdLiveBindingType_Offset(binding, forInstance: true))
.Value = offset;
}
}
}
internal static partial class OpenUsdRepresentationLog
{
[LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdRepresentation + 1,
Level = LogLevel.Information,
Message = "Materialised OpenUSD facility (root {RootId}, PlantStage {StageId}).")]
public static partial void MaterialisedOpenUsdFacility(this ILogger logger, NodeId rootId, NodeId stageId);
}
}