diff --git a/docs/DeviceIntegration.md b/docs/DeviceIntegration.md index 8a420ce5e0..f88593693a 100644 --- a/docs/DeviceIntegration.md +++ b/docs/DeviceIntegration.md @@ -38,7 +38,11 @@ companion-spec server that exposes a configurable number of fully simulated `PumpType` instances. The sample uses the hand-written fluent node-manager style for the common pump model and simulation wiring, then uses `ConfigureDevicesFor` with the topology-element builder to add an -ad-hoc Diagnostics functional group to each pump. +ad-hoc Diagnostics functional group to each pump. The simulated asset is +specified by its own product datasheet, +[`samples/PumpDeviceIntegrationServer/DATASHEET.md`](../samples/PumpDeviceIntegrationServer/DATASHEET.md), +which maps every nameplate field, engineering range and alarm trip point +to its OPC UA browse path. ## Quick start diff --git a/docs/NodeManagers.md b/docs/NodeManagers.md index e09cc4c7d8..6723c3bf6d 100644 --- a/docs/NodeManagers.md +++ b/docs/NodeManagers.md @@ -1172,17 +1172,17 @@ child on demand (matching the runtime's `AddEngineeringUnits` / `AddEURange` helpers) and then set the Value attribute. ```csharp -builder.Variable("Pumps/Pump #1/Operational/Measurements/FluidTemperature") +builder.Variable("Pumps/Pump_1/Operational/Measurements/FluidTemperature") .OnRead(SimulateTemperature) .WithEngineeringUnits( new EUInformation("K", "Kelvin", "http://www.opcfoundation.org/UA/units/un/cefact")) - .WithEURange(min: 233.15, max: 473.15); + .WithEURange(min: 263.15, max: 393.15); // Convenience: set both at once. -builder.Variable("Pumps/Pump #1/Operational/Measurements/Pressure") +builder.Variable("Pumps/Pump_1/Operational/Measurements/DifferentialPressure") .OnRead(SimulatePressure) - .WithUnits(EUInformations.Pascal, min: 0, max: 1_000_000); + .WithUnits(EUInformations.Pascal, min: 0, max: 400_000); ``` Fail-fast behaviour: calling these on a non-`BaseAnalogState` variable @@ -1200,15 +1200,20 @@ Typed overloads exist for every built-in OPC UA scalar (`string`, hatch. ```csharp -builder.Node("Pumps/Pump #1/Identification") - .WithProperty("Manufacturer", "SimPump Corp") - .WithProperty("Model", "PumpX-2000") +builder.Node("Pumps/Pump_1/Identification") + .WithProperty("Manufacturer", new LocalizedText("SimPump Corp")) + .WithProperty("Model", new LocalizedText("PumpX-2000")) .WithProperty("SerialNumber", "SN-001") .WithProperty("DeviceClass", "Pump") .WithProperty("ProductInstanceUri", "urn:simdevice:SimPump:PumpX-2000:SN-001"); ``` +Pass the CLR type the model declares for the property — `LocalizedText` +for `Manufacturer` / `Model` / `ComponentName`, `ushort` for +`YearOfConstruction`, `byte` for `MonthOfConstruction`, and so on. The +typed overloads make the choice explicit at the call site. + Reference resolution is by browse-name only (case-sensitive, namespace-agnostic), matching the AOT-safe constraint of the rest of the fluent surface. When the child exists it is updated; when it exists diff --git a/docs/OpenUsd.md b/docs/OpenUsd.md index fdf424fdbc..37e32576b3 100644 --- a/docs/OpenUsd.md +++ b/docs/OpenUsd.md @@ -331,7 +331,7 @@ agree: ```csharp NodeId target = result.ResolveBindingTargetNodeId( - "/Plant/Pumps/P101/Pump/Impeller", "xformOp:rotateZ"); + "/Plant/Pumps/Pump_1/Pump/Impeller", "xformOp:rotateZ"); ``` ### Conformance units diff --git a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda index 631e7921d3..76ae9c9f25 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda @@ -12,9 +12,7 @@ /Plant/Pumps/P101/Body primvars:displayColor(BearingTemperature -> DisplayColor) /Plant/Pumps/P101/Body/Mat/Surface.inputs:diffuseColor (BearingTemperature -> DisplayColor) - /Plant/Pumps/P101/StatusLight/Mat/Surface.inputs:emissiveColor - (DifferentialPressure -> EmissiveColor) - /Plant/Pumps/P101/StatusLight.visibility (alarm ActiveState -> Visibility) + /Plant/Pumps/P101/AlarmRing.visibility (supervision alarm -> Visibility) /Plant/Pumps/P101/Impeller.inputs:speedSetpoint (opt-in command intent -> UA write) MODEL @@ -151,20 +149,25 @@ def Xform "Plant" uniform token[] xformOpOrder = ["xformOp:rotateXYZ"] } - # Three-quarter view along the pump line. A horizontal end-suction pump is - # all profile, so an overhead camera shows little but baseplate; this one - # looks down the line from the front-right and slightly above, which reads - # the volute, the coupling guard and the motor. Hosts start on it by prim - # path (the connector's --camera option). + # Operator's viewpoint. A person stands in the aisle on the discharge side + # of the line at eye height and looks across the row: every configured pump + # is in shot at a three-quarter angle, so the volute, the coupling guard, + # the gauges and the turning shaft all read on each machine. The pitch is + # only 7 degrees below horizontal - the gantry view a steeper camera gives + # shows little but baseplate. The suction vessels stand behind the pumps + # from here and the aggregation aisle further back again, which gives the + # shot its depth. The framing holds from one pump up to eight, so it does + # not have to be retuned for --pumps N. Hosts start on it by prim path (the + # connector's --camera option). def Camera "HeroCamera" { token projection = "perspective" - float focalLength = 24 + float focalLength = 20 float horizontalAperture = 20.955 float verticalAperture = 13.97 - float2 clippingRange = (0.1, 200) - double3 xformOp:translate = (6.0, -5.94, 3.77) - double3 xformOp:rotateXYZ = (70, 0, 41) + float2 clippingRange = (0.1, 300) + double3 xformOp:translate = (8.0, -4.5, 1.65) + double3 xformOp:rotateXYZ = (83, 0, 45) uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ"] } @@ -173,7 +176,7 @@ def Xform "Plant" int[] faceVertexCounts = [4] int[] faceVertexIndices = [0, 1, 2, 3] point3f[] points = [ - (-4, -3.4, 0), (7, -3.4, 0), (7, 4.2, 0), (-4, 4.2, 0) + (-4, -3, 0), (4, -3, 0), (4, 14, 0), (-4, 14, 0) ] normal3f[] primvars:normals = [ (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1) @@ -185,7 +188,15 @@ def Xform "Plant" def Scope "Pumps" { + # Authoring master for the pump component asset. The server composes + # one referenced prim per configured pump into this scope, so + # rendering the master as well would show a phantom extra machine that + # no OPC UA object drives. It stays in the layer - deactivated - because + # generate_pump_assets.py extracts pump.usda from + # it. Edit it, re-run the generator, never hand-edit the generated + # layer. def Xform "P101" ( + active = false kind = "component" ) { @@ -370,9 +381,37 @@ def Xform "Plant" double height = 0.055 double radius = 0.051 color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] - rel material:binding = + rel material:binding = double3 xformOp:translate = (-0.822, 0, 0.28) uniform token[] xformOpOrder = ["xformOp:translate"] + + # Own material, so the suction line can be tinted by the + # pumped fluid temperature without recolouring every other + # blue casting that shares the Looks scope. + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.22, 0.55) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + } + + # Cavitation indication, at the suction eye where the fault is. + # Hidden until the supervision state asserts. + def Sphere "CavitationHalo" + { + double radius = 0.090 + color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] + token visibility = "invisible" + double3 xformOp:translate = (-0.845, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] } def Cylinder "BoltTop" ( @@ -535,6 +574,33 @@ def Xform "Plant" double3 xformOp:translate = (-0.645, 0.108, 0.470) uniform token[] xformOpOrder = ["xformOp:translate"] } + + # Discharge pressure needle. The binding contract drives + # xformOp:rotateZ, but this dial faces +Y, so a static rotateX + # lays the needle's local Z along the world Y axis before the + # driven rotation - the same trick the impeller uses to put a + # horizontal shaft on a rotateZ binding. + def Xform "Needle" + { + double3 xformOp:translate = (-0.645, 0.121, 0.470) + double xformOp:rotateX = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateX", "xformOp:rotateZ" + ] + + def Cube "Pointer" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.75, 0.10, 0.08)] + rel material:binding = + double3 xformOp:translate = (0.015, 0, 0) + double3 xformOp:scale = (0.030, 0.0035, 0.0035) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } } } @@ -603,6 +669,110 @@ def Xform "Plant" double3 xformOp:translate = (-0.330, 0.064, 0.245) uniform token[] xformOpOrder = ["xformOp:translate"] } + + # Bearing-temperature gauge on the bracket. Same rotateX trick as + # the discharge gauge: the dial faces +Y, the binding drives Z. + def Xform "TempGauge" + { + def Cylinder "Dial" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.018 + double radius = 0.032 + color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] + rel material:binding = + double3 xformOp:translate = (-0.300, 0.070, 0.345) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Needle" + { + double3 xformOp:translate = (-0.300, 0.081, 0.345) + double xformOp:rotateX = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateX", "xformOp:rotateZ" + ] + + def Cube "Pointer" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.75, 0.10, 0.08)] + rel material:binding = + double3 xformOp:translate = (0.012, 0, 0) + double3 xformOp:scale = (0.024, 0.003, 0.003) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + } + } + + # Suction vessel the pump draws from. Drawn at a fraction of a real + # vessel's height so it does not tower over the machine; the liquid + # surface rides on the published Level. + def Xform "SuctionVessel" + { + double3 xformOp:translate = (-1.35, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Cylinder "Shell" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 1.05 + double radius = 0.30 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.525) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Skirt" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.04 + double radius = 0.32 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.020) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + # The liquid surface. Its height above the vessel floor is driven + # by the published suction level, so the twin shows the pump + # running down its supply and refilling. + def Cylinder "Surface" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.012 + double radius = 0.285 + color3f[] primvars:displayColor = [(0.10, 0.42, 0.72)] + rel material:binding = + matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0.5, 1) ) + uniform token[] xformOpOrder = ["xformOp:transform"] + } + + def Cylinder "DownPipe" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.42 + double radius = 0.040 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.28, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } } def Cylinder "Bearing" ( @@ -618,6 +788,18 @@ def Xform "Plant" uniform token[] xformOpOrder = ["xformOp:translate"] } + # Motor-overheat indication, on the bearing bracket where the fault + # is. Hidden until the supervision state asserts, so it says which + # fault the alarm ring is drawn for. + def Sphere "OverheatHalo" + { + double radius = 0.105 + color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] + token visibility = "invisible" + double3 xformOp:translate = (-0.300, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + def Xform "Impeller" { double3 xformOp:translate = (0, 0, 0.28) @@ -941,6 +1123,80 @@ def Xform "Plant" uniform token[] xformOpOrder = ["xformOp:translate"] } + # Cooling fan inside the cowl. It sits on the motor shaft, so it + # turns with the duty point: a pump that is barely moving fluid is + # visibly loafing. Same rotateY-then-rotateZ trick as the impeller, + # because the shaft is horizontal but the binding drives Z. + def Xform "FanBlades" + { + double3 xformOp:translate = (0.660, 0, 0.28) + double xformOp:rotateY = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" + ] + + def Cylinder "Hub" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.030 + double radius = 0.028 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + uniform token[] xformOpOrder = [] + } + + def Cube "BladeA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0.055, 0, 0) + double3 xformOp:scale = (0.110, 0.028, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (-0.055, 0, 0) + double3 xformOp:scale = (0.110, 0.028, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, 0.055, 0) + double3 xformOp:scale = (0.028, 0.110, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeD" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, -0.055, 0) + double3 xformOp:scale = (0.028, 0.110, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + def Cube "FinTop" ( prepend apiSchemas = ["MaterialBindingAPI"] ) @@ -1082,119 +1338,37 @@ def Xform "Plant" } } - # Beacon hardware. Permanently mounted on a real skid, so it sits - # outside the alarm-gated prim below - only the lamp itself is gated. - def Xform "Beacon" - { - double3 xformOp:translate = (0.10, -0.32, 0.12) - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Cylinder "Post" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.520 - double radius = 0.016 - color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.260) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Housing" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.050 - double radius = 0.050 - color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.545) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - # The lamp itself is permanently mounted; its emissive colour is - # driven by discharge pressure, so its glow tracks the duty point - # and shows at a glance that the pump is running. It borrows the - # material the emissive binding targets under StatusLight. - def Sphere "Lamp" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.050 - color3f[] primvars:displayColor = [(0.90, 0.20, 0.16)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.585) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - - def Xform "StatusLight" + # Alarm ring. A red circle drawn on the floor around the + # machine is legible from anywhere in the hall and from any + # camera angle, which a lamp on a mast is not: a beacon reads + # only when you happen to be looking at it. Hidden until the + # pump's own supervision state asserts. + def Mesh "AlarmRing" { - double3 xformOp:translate = (0.10, -0.32, 0.12) - uniform token[] xformOpOrder = ["xformOp:translate"] - - token visibility = "inherited" - - # Alarm halo - only this is gated by the supervision alarm, so a - # cleared alarm leaves the beacon hardware and lamp on show. - def Sphere "Halo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.085 - color3f[] primvars:displayColor = [(1.0, 0.35, 0.10)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.585) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Material "Mat" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.1, 0.1, 0.1) - color3f inputs:emissiveColor = (0, 0, 0) - float inputs:roughness = 0.25 - token outputs:surface - } - } + int[] faceVertexCounts = [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + int[] faceVertexIndices = [0, 2, 3, 1, 2, 4, 5, 3, 4, 6, 7, 5, 6, 8, 9, 7, 8, 10, 11, 9, 10, 12, 13, 11, 12, 14, 15, 13, 14, 16, 17, 15, 16, 18, 19, 17, 18, 20, 21, 19, 20, 22, 23, 21, 22, 24, 25, 23, 24, 26, 27, 25, 26, 28, 29, 27, 28, 30, 31, 29, 30, 32, 33, 31, 32, 34, 35, 33, 34, 36, 37, 35, 36, 38, 39, 37, 38, 40, 41, 39, 40, 42, 43, 41, 42, 44, 45, 43, 44, 46, 47, 45, 46, 48, 49, 47, 48, 50, 51, 49, 50, 52, 53, 51, 52, 54, 55, 53, 54, 56, 57, 55, 56, 58, 59, 57, 58, 60, 61, 59, 60, 62, 63, 61, 62, 64, 65, 63, 64, 66, 67, 65, 66, 68, 69, 67, 68, 70, 71, 69, 70, 72, 73, 71, 72, 74, 75, 73, 74, 76, 77, 75, 76, 78, 79, 77, 78, 80, 81, 79, 80, 82, 83, 81, 82, 84, 85, 83, 84, 86, 87, 85, 86, 88, 89, 87, 88, 90, 91, 89, 90, 92, 93, 91, 92, 94, 95, 93, 94, 0, 1, 95] + point3f[] points = [ + (1.0200, 0.0000, 0.0150), (0.8800, 0.0000, 0.0150), (1.0106, 0.1436, 0.0150), (0.8718, 0.1253, 0.0150), (0.9825, 0.2847, 0.0150), (0.8473, 0.2485, 0.0150), + (0.9363, 0.4210, 0.0150), (0.8069, 0.3674, 0.0150), (0.8726, 0.5500, 0.0150), (0.7514, 0.4800, 0.0150), (0.7927, 0.6696, 0.0150), (0.6816, 0.5844, 0.0150), + (0.6978, 0.7778, 0.0150), (0.5988, 0.6788, 0.0150), (0.5896, 0.8727, 0.0150), (0.5044, 0.7616, 0.0150), (0.4700, 0.9526, 0.0150), (0.4000, 0.8314, 0.0150), + (0.3410, 1.0163, 0.0150), (0.2874, 0.8869, 0.0150), (0.2047, 1.0625, 0.0150), (0.1685, 0.9273, 0.0150), (0.0636, 1.0906, 0.0150), (0.0453, 0.9518, 0.0150), + (-0.0800, 1.1000, 0.0150), (-0.0800, 0.9600, 0.0150), (-0.2236, 1.0906, 0.0150), (-0.2053, 0.9518, 0.0150), (-0.3647, 1.0625, 0.0150), (-0.3285, 0.9273, 0.0150), + (-0.5010, 1.0163, 0.0150), (-0.4474, 0.8869, 0.0150), (-0.6300, 0.9526, 0.0150), (-0.5600, 0.8314, 0.0150), (-0.7496, 0.8727, 0.0150), (-0.6644, 0.7616, 0.0150), + (-0.8578, 0.7778, 0.0150), (-0.7588, 0.6788, 0.0150), (-0.9527, 0.6696, 0.0150), (-0.8416, 0.5844, 0.0150), (-1.0326, 0.5500, 0.0150), (-0.9114, 0.4800, 0.0150), + (-1.0963, 0.4210, 0.0150), (-0.9669, 0.3674, 0.0150), (-1.1425, 0.2847, 0.0150), (-1.0073, 0.2485, 0.0150), (-1.1706, 0.1436, 0.0150), (-1.0318, 0.1253, 0.0150), + (-1.1800, 0.0000, 0.0150), (-1.0400, 0.0000, 0.0150), (-1.1706, -0.1436, 0.0150), (-1.0318, -0.1253, 0.0150), (-1.1425, -0.2847, 0.0150), (-1.0073, -0.2485, 0.0150), + (-1.0963, -0.4210, 0.0150), (-0.9669, -0.3674, 0.0150), (-1.0326, -0.5500, 0.0150), (-0.9114, -0.4800, 0.0150), (-0.9527, -0.6696, 0.0150), (-0.8416, -0.5844, 0.0150), + (-0.8578, -0.7778, 0.0150), (-0.7588, -0.6788, 0.0150), (-0.7496, -0.8727, 0.0150), (-0.6644, -0.7616, 0.0150), (-0.6300, -0.9526, 0.0150), (-0.5600, -0.8314, 0.0150), + (-0.5010, -1.0163, 0.0150), (-0.4474, -0.8869, 0.0150), (-0.3647, -1.0625, 0.0150), (-0.3285, -0.9273, 0.0150), (-0.2236, -1.0906, 0.0150), (-0.2053, -0.9518, 0.0150), + (-0.0800, -1.1000, 0.0150), (-0.0800, -0.9600, 0.0150), (0.0636, -1.0906, 0.0150), (0.0453, -0.9518, 0.0150), (0.2047, -1.0625, 0.0150), (0.1685, -0.9273, 0.0150), + (0.3410, -1.0163, 0.0150), (0.2874, -0.8869, 0.0150), (0.4700, -0.9526, 0.0150), (0.4000, -0.8314, 0.0150), (0.5896, -0.8727, 0.0150), (0.5044, -0.7616, 0.0150), + (0.6978, -0.7778, 0.0150), (0.5988, -0.6788, 0.0150), (0.7927, -0.6696, 0.0150), (0.6816, -0.5844, 0.0150), (0.8726, -0.5500, 0.0150), (0.7514, -0.4800, 0.0150), + (0.9363, -0.4210, 0.0150), (0.8069, -0.3674, 0.0150), (0.9825, -0.2847, 0.0150), (0.8473, -0.2485, 0.0150), (1.0106, -0.1436, 0.0150), (0.8718, -0.1253, 0.0150) + ] + color3f[] primvars:displayColor = [(0.90, 0.05, 0.05)] + token visibility = "invisible" } } } - def Xform "Line1" - { - def Scope "Pumps" - { - def Xform "P_201" - { - double3 xformOp:translate = (0, 1.5, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Xform "P_202" - { - double3 xformOp:translate = (0, 2.7, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Xform "P_203" - { - double3 xformOp:translate = (0, 3.9, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - - def Xform "RemotePump" - { - double3 xformOp:translate = (0, -1.5, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } } diff --git a/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py index c9a9c0db93..be0d2cf375 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py +++ b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py @@ -1,9 +1,9 @@ -"""Regenerates the referenced pump assets from the P101 master in Plant.usda. +"""Regenerates the referenced pump asset from the P101 master in Plant.usda. -pump.usda and remote-pump.usda are the component assets a ProductionLine -references once per aggregated pump. They must carry the same geometry as -/Plant/Pumps/P101 so every pump in the line renders at full fidelity, but they -have to be self-contained: material paths are rewritten to a local Looks scope. +pump.usda is the component asset the server references once per configured +pump. It must carry the same geometry as /Plant/Pumps/P101 so every pump in the +hall renders at full fidelity, but it has to be self-contained: material paths +are rewritten to a local Looks scope. Run from the repository root: python samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py @@ -15,30 +15,26 @@ HERE = pathlib.Path(__file__).parent PLANT = HERE / "Plant.usda" -# The P101 children that make up the machine itself. Beacon and StatusLight are -# plant-side signalling equipment, not part of the pump component asset. +# The P101 children that make up the machine itself, including the indications. +# Every configured pump is composed from this asset and driven by its own +# bindings, so each machine carries its own gauges, suction vessel and alarm +# ring rather than sharing one plant-side set. WANTED = [ "Baseplate", "Body", "Casing", "Suction", + "SuctionVessel", "Discharge", "PowerEnd", "Bearing", + "OverheatHalo", "Impeller", "CouplingGuard", "Motor", + "AlarmRing", ] -# KSB signal blue (RAL 5005) -> OEM moss green (RAL 6011), so the pump federated -# from the remote server is obvious at a glance. -GREEN = { - "(0.02, 0.24, 0.60)": "(0.24, 0.36, 0.20)", - "(0.02, 0.22, 0.55)": "(0.22, 0.33, 0.18)", - "(0.02, 0.20, 0.50)": "(0.20, 0.30, 0.16)", -} - - def block(lines, start): """Returns the line range of a brace-delimited prim block starting at `start`.""" depth = 0 @@ -80,13 +76,10 @@ def rehome(text): return text -def build(plant, doc, recolour): +def build(plant, doc): parts = [looks(plant)] parts += [extract(plant, name, 12) for name in WANTED] body = rehome("\n\n".join(parts)) - if recolour: - for blue, green in GREEN.items(): - body = body.replace(blue, green) return f'''#usda 1.0 ( doc = """{doc}""" @@ -99,44 +92,45 @@ def Xform "Pump" ( kind = "component" ) {{ + matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1) ) + uniform token[] xformOpOrder = ["xformOp:transform"] + {body} }} ''' PUMP_DOC = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings - composition demo. A ProductionLine aggregates 1..n pumps by *referencing* - (and instancing) this asset's default prim once per aggregated pump - (spec 5.12, CompositionArc = Instance/Reference). The cross-server component - references an equivalent OEM asset (remote-pump.usda) on another server (5.14). - - Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in - Plant.usda, so every aggregated pump renders at the same fidelity as the hero - pump: an EN 733 size 65-200 end-suction centrifugal pump (Grundfos NK / - KSB Etanorm proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m - baseplate, shaft centreline 0.160 m above the baseplate. Do not edit by hand - - edit P101 and re-run the generator. - - The plant-side signalling equipment (Beacon, StatusLight) is deliberately not - part of the component asset.""" - -REMOTE_DOC = """OEM pump asset served by a *different* OPC UA server, referenced - across the federation boundary by the cross-server binding - (spec 5.14). Geometrically identical to pump.usda so the composed line reads - consistently, but wearing the OEM's own livery - moss green (RAL 6011) - castings instead of KSB signal blue. + pump twin. The server composes one referenced prim per configured + pump (--pumps N), so the rendered hall holds exactly the pumps the connected + server simulates - nothing else (spec 5.12, CompositionArc = Reference). Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in - Plant.usda. Do not edit by hand - edit P101 and re-run the generator.""" + Plant.usda, so every composed pump renders at the same fidelity: an EN 733 + size 65-200 end-suction centrifugal pump (Grundfos NK / KSB Etanorm + proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m baseplate, shaft + centreline 0.160 m above the baseplate. Do not edit by hand - edit P101 and + re-run the generator. + + The asset carries its own instrumentation and indications - pressure and + bearing-temperature gauges, suction vessel, cooling fan, the alarm ring and + the two fault halos - because each composed pump is driven by its own + bindings and has to show its own state. + + The root prim declares an identity xformOp:transform and lists it in + xformOpOrder because that is what makes the bay position binding work. A + connector resolves a Translation render target into a single matrix op, and + USD only evaluates ops named in xformOpOrder - a list that is uniform, so the + connector cannot add itself to it from a stronger layer. If the asset omits + the op, the bay position is silently discarded, every composed pump renders + on the origin, and the hall looks like it holds a single machine.""" def main(): plant = PLANT.read_text(encoding="utf-8") (HERE / "pump.usda").write_text( - build(plant, PUMP_DOC, recolour=False), encoding="utf-8", newline="\r\n") - (HERE / "remote-pump.usda").write_text( - build(plant, REMOTE_DOC, recolour=True), encoding="utf-8", newline="\r\n") - print("wrote pump.usda and remote-pump.usda") + build(plant, PUMP_DOC), encoding="utf-8", newline="\r\n") + print("wrote pump.usda") if __name__ == "__main__": diff --git a/samples/PumpDeviceIntegrationServer/Assets/pump.usda b/samples/PumpDeviceIntegrationServer/Assets/pump.usda index d39fb9c344..84c5e70b97 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/pump.usda @@ -1,20 +1,29 @@ #usda 1.0 ( doc = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings - composition demo. A ProductionLine aggregates 1..n pumps by *referencing* - (and instancing) this asset's default prim once per aggregated pump - (spec 5.12, CompositionArc = Instance/Reference). The cross-server component - references an equivalent OEM asset (remote-pump.usda) on another server (5.14). + pump twin. The server composes one referenced prim per configured + pump (--pumps N), so the rendered hall holds exactly the pumps the connected + server simulates - nothing else (spec 5.12, CompositionArc = Reference). Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in - Plant.usda, so every aggregated pump renders at the same fidelity as the hero - pump: an EN 733 size 65-200 end-suction centrifugal pump (Grundfos NK / - KSB Etanorm proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m - baseplate, shaft centreline 0.160 m above the baseplate. Do not edit by hand - - edit P101 and re-run the generator. - - The plant-side signalling equipment (Beacon, StatusLight) is deliberately not - part of the component asset.""" + Plant.usda, so every composed pump renders at the same fidelity: an EN 733 + size 65-200 end-suction centrifugal pump (Grundfos NK / KSB Etanorm + proportions) driven by an IEC 160M motor on a 1.80 x 0.46 m baseplate, shaft + centreline 0.160 m above the baseplate. Do not edit by hand - edit P101 and + re-run the generator. + + The asset carries its own instrumentation and indications - pressure and + bearing-temperature gauges, suction vessel, cooling fan, the alarm ring and + the two fault halos - because each composed pump is driven by its own + bindings and has to show its own state. + + The root prim declares an identity xformOp:transform and lists it in + xformOpOrder because that is what makes the bay position binding work. A + connector resolves a Translation render target into a single matrix op, and + USD only evaluates ops named in xformOpOrder - a list that is uniform, so the + connector cannot add itself to it from a stronger layer. If the asset omits + the op, the bay position is silently discarded, every composed pump renders + on the origin, and the hall looks like it holds a single machine.""" defaultPrim = "Pump" metersPerUnit = 1 upAxis = "Z" @@ -24,6 +33,9 @@ def Xform "Pump" ( kind = "component" ) { + matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1) ) + uniform token[] xformOpOrder = ["xformOp:transform"] + def Scope "Looks" { def Material "PumpBlue" @@ -275,9 +287,37 @@ def Xform "Pump" ( double height = 0.055 double radius = 0.051 color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] - rel material:binding = + rel material:binding = double3 xformOp:translate = (-0.822, 0, 0.28) uniform token[] xformOpOrder = ["xformOp:translate"] + + # Own material, so the suction line can be tinted by the + # pumped fluid temperature without recolouring every other + # blue casting that shares the Looks scope. + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.02, 0.22, 0.55) + float inputs:metallic = 0.1 + float inputs:roughness = 0.45 + token outputs:surface + } + } + } + + # Cavitation indication, at the suction eye where the fault is. + # Hidden until the supervision state asserts. + def Sphere "CavitationHalo" + { + double radius = 0.090 + color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] + token visibility = "invisible" + double3 xformOp:translate = (-0.845, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] } def Cylinder "BoltTop" ( @@ -333,6 +373,67 @@ def Xform "Pump" ( } } + def Xform "SuctionVessel" + { + double3 xformOp:translate = (-1.35, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + + def Cylinder "Shell" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 1.05 + double radius = 0.30 + color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.525) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "Skirt" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.04 + double radius = 0.32 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, 0, 0.020) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + # The liquid surface. Its height above the vessel floor is driven + # by the published suction level, so the twin shows the pump + # running down its supply and refilling. + def Cylinder "Surface" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.012 + double radius = 0.285 + color3f[] primvars:displayColor = [(0.10, 0.42, 0.72)] + rel material:binding = + matrix4d xformOp:transform = ( (1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0.5, 1) ) + uniform token[] xformOpOrder = ["xformOp:transform"] + } + + def Cylinder "DownPipe" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.42 + double radius = 0.040 + color3f[] primvars:displayColor = [(0.02, 0.22, 0.55)] + rel material:binding = + double3 xformOp:translate = (0.28, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + def Xform "Discharge" { def Cylinder "Neck" ( @@ -440,6 +541,33 @@ def Xform "Pump" ( double3 xformOp:translate = (-0.645, 0.108, 0.470) uniform token[] xformOpOrder = ["xformOp:translate"] } + + # Discharge pressure needle. The binding contract drives + # xformOp:rotateZ, but this dial faces +Y, so a static rotateX + # lays the needle's local Z along the world Y axis before the + # driven rotation - the same trick the impeller uses to put a + # horizontal shaft on a rotateZ binding. + def Xform "Needle" + { + double3 xformOp:translate = (-0.645, 0.121, 0.470) + double xformOp:rotateX = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateX", "xformOp:rotateZ" + ] + + def Cube "Pointer" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.75, 0.10, 0.08)] + rel material:binding = + double3 xformOp:translate = (0.015, 0, 0) + double3 xformOp:scale = (0.030, 0.0035, 0.0035) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } } } @@ -508,6 +636,46 @@ def Xform "Pump" ( double3 xformOp:translate = (-0.330, 0.064, 0.245) uniform token[] xformOpOrder = ["xformOp:translate"] } + + # Bearing-temperature gauge on the bracket. Same rotateX trick as + # the discharge gauge: the dial faces +Y, the binding drives Z. + def Xform "TempGauge" + { + def Cylinder "Dial" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Y" + double height = 0.018 + double radius = 0.032 + color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] + rel material:binding = + double3 xformOp:translate = (-0.300, 0.070, 0.345) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Xform "Needle" + { + double3 xformOp:translate = (-0.300, 0.081, 0.345) + double xformOp:rotateX = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateX", "xformOp:rotateZ" + ] + + def Cube "Pointer" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.75, 0.10, 0.08)] + rel material:binding = + double3 xformOp:translate = (0.012, 0, 0) + double3 xformOp:scale = (0.024, 0.003, 0.003) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + } } def Cylinder "Bearing" ( @@ -523,6 +691,15 @@ def Xform "Pump" ( uniform token[] xformOpOrder = ["xformOp:translate"] } + def Sphere "OverheatHalo" + { + double radius = 0.105 + color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] + token visibility = "invisible" + double3 xformOp:translate = (-0.300, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + def Xform "Impeller" { double3 xformOp:translate = (0, 0, 0.28) @@ -846,6 +1023,80 @@ def Xform "Pump" ( uniform token[] xformOpOrder = ["xformOp:translate"] } + # Cooling fan inside the cowl. It sits on the motor shaft, so it + # turns with the duty point: a pump that is barely moving fluid is + # visibly loafing. Same rotateY-then-rotateZ trick as the impeller, + # because the shaft is horizontal but the binding drives Z. + def Xform "FanBlades" + { + double3 xformOp:translate = (0.660, 0, 0.28) + double xformOp:rotateY = 90 + double xformOp:rotateZ = 0 + uniform token[] xformOpOrder = [ + "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" + ] + + def Cylinder "Hub" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "Z" + double height = 0.030 + double radius = 0.028 + color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] + rel material:binding = + uniform token[] xformOpOrder = [] + } + + def Cube "BladeA" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0.055, 0, 0) + double3 xformOp:scale = (0.110, 0.028, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeB" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (-0.055, 0, 0) + double3 xformOp:scale = (0.110, 0.028, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeC" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, 0.055, 0) + double3 xformOp:scale = (0.028, 0.110, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + + def Cube "BladeD" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double size = 1.0 + color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] + rel material:binding = + double3 xformOp:translate = (0, -0.055, 0) + double3 xformOp:scale = (0.028, 0.110, 0.008) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } + } + def Cube "FinTop" ( prepend apiSchemas = ["MaterialBindingAPI"] ) @@ -986,4 +1237,30 @@ def Xform "Pump" ( uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] } } + + def Mesh "AlarmRing" + { + int[] faceVertexCounts = [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + int[] faceVertexIndices = [0, 2, 3, 1, 2, 4, 5, 3, 4, 6, 7, 5, 6, 8, 9, 7, 8, 10, 11, 9, 10, 12, 13, 11, 12, 14, 15, 13, 14, 16, 17, 15, 16, 18, 19, 17, 18, 20, 21, 19, 20, 22, 23, 21, 22, 24, 25, 23, 24, 26, 27, 25, 26, 28, 29, 27, 28, 30, 31, 29, 30, 32, 33, 31, 32, 34, 35, 33, 34, 36, 37, 35, 36, 38, 39, 37, 38, 40, 41, 39, 40, 42, 43, 41, 42, 44, 45, 43, 44, 46, 47, 45, 46, 48, 49, 47, 48, 50, 51, 49, 50, 52, 53, 51, 52, 54, 55, 53, 54, 56, 57, 55, 56, 58, 59, 57, 58, 60, 61, 59, 60, 62, 63, 61, 62, 64, 65, 63, 64, 66, 67, 65, 66, 68, 69, 67, 68, 70, 71, 69, 70, 72, 73, 71, 72, 74, 75, 73, 74, 76, 77, 75, 76, 78, 79, 77, 78, 80, 81, 79, 80, 82, 83, 81, 82, 84, 85, 83, 84, 86, 87, 85, 86, 88, 89, 87, 88, 90, 91, 89, 90, 92, 93, 91, 92, 94, 95, 93, 94, 0, 1, 95] + point3f[] points = [ + (1.0200, 0.0000, 0.0150), (0.8800, 0.0000, 0.0150), (1.0106, 0.1436, 0.0150), (0.8718, 0.1253, 0.0150), (0.9825, 0.2847, 0.0150), (0.8473, 0.2485, 0.0150), + (0.9363, 0.4210, 0.0150), (0.8069, 0.3674, 0.0150), (0.8726, 0.5500, 0.0150), (0.7514, 0.4800, 0.0150), (0.7927, 0.6696, 0.0150), (0.6816, 0.5844, 0.0150), + (0.6978, 0.7778, 0.0150), (0.5988, 0.6788, 0.0150), (0.5896, 0.8727, 0.0150), (0.5044, 0.7616, 0.0150), (0.4700, 0.9526, 0.0150), (0.4000, 0.8314, 0.0150), + (0.3410, 1.0163, 0.0150), (0.2874, 0.8869, 0.0150), (0.2047, 1.0625, 0.0150), (0.1685, 0.9273, 0.0150), (0.0636, 1.0906, 0.0150), (0.0453, 0.9518, 0.0150), + (-0.0800, 1.1000, 0.0150), (-0.0800, 0.9600, 0.0150), (-0.2236, 1.0906, 0.0150), (-0.2053, 0.9518, 0.0150), (-0.3647, 1.0625, 0.0150), (-0.3285, 0.9273, 0.0150), + (-0.5010, 1.0163, 0.0150), (-0.4474, 0.8869, 0.0150), (-0.6300, 0.9526, 0.0150), (-0.5600, 0.8314, 0.0150), (-0.7496, 0.8727, 0.0150), (-0.6644, 0.7616, 0.0150), + (-0.8578, 0.7778, 0.0150), (-0.7588, 0.6788, 0.0150), (-0.9527, 0.6696, 0.0150), (-0.8416, 0.5844, 0.0150), (-1.0326, 0.5500, 0.0150), (-0.9114, 0.4800, 0.0150), + (-1.0963, 0.4210, 0.0150), (-0.9669, 0.3674, 0.0150), (-1.1425, 0.2847, 0.0150), (-1.0073, 0.2485, 0.0150), (-1.1706, 0.1436, 0.0150), (-1.0318, 0.1253, 0.0150), + (-1.1800, 0.0000, 0.0150), (-1.0400, 0.0000, 0.0150), (-1.1706, -0.1436, 0.0150), (-1.0318, -0.1253, 0.0150), (-1.1425, -0.2847, 0.0150), (-1.0073, -0.2485, 0.0150), + (-1.0963, -0.4210, 0.0150), (-0.9669, -0.3674, 0.0150), (-1.0326, -0.5500, 0.0150), (-0.9114, -0.4800, 0.0150), (-0.9527, -0.6696, 0.0150), (-0.8416, -0.5844, 0.0150), + (-0.8578, -0.7778, 0.0150), (-0.7588, -0.6788, 0.0150), (-0.7496, -0.8727, 0.0150), (-0.6644, -0.7616, 0.0150), (-0.6300, -0.9526, 0.0150), (-0.5600, -0.8314, 0.0150), + (-0.5010, -1.0163, 0.0150), (-0.4474, -0.8869, 0.0150), (-0.3647, -1.0625, 0.0150), (-0.3285, -0.9273, 0.0150), (-0.2236, -1.0906, 0.0150), (-0.2053, -0.9518, 0.0150), + (-0.0800, -1.1000, 0.0150), (-0.0800, -0.9600, 0.0150), (0.0636, -1.0906, 0.0150), (0.0453, -0.9518, 0.0150), (0.2047, -1.0625, 0.0150), (0.1685, -0.9273, 0.0150), + (0.3410, -1.0163, 0.0150), (0.2874, -0.8869, 0.0150), (0.4700, -0.9526, 0.0150), (0.4000, -0.8314, 0.0150), (0.5896, -0.8727, 0.0150), (0.5044, -0.7616, 0.0150), + (0.6978, -0.7778, 0.0150), (0.5988, -0.6788, 0.0150), (0.7927, -0.6696, 0.0150), (0.6816, -0.5844, 0.0150), (0.8726, -0.5500, 0.0150), (0.7514, -0.4800, 0.0150), + (0.9363, -0.4210, 0.0150), (0.8069, -0.3674, 0.0150), (0.9825, -0.2847, 0.0150), (0.8473, -0.2485, 0.0150), (1.0106, -0.1436, 0.0150), (0.8718, -0.1253, 0.0150) + ] + color3f[] primvars:displayColor = [(0.90, 0.05, 0.05)] + token visibility = "invisible" + } } diff --git a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda deleted file mode 100644 index cf57beaa41..0000000000 --- a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda +++ /dev/null @@ -1,982 +0,0 @@ -#usda 1.0 -( - doc = """OEM pump asset served by a *different* OPC UA server, referenced - across the federation boundary by the cross-server binding - (spec 5.14). Geometrically identical to pump.usda so the composed line reads - consistently, but wearing the OEM's own livery - moss green (RAL 6011) - castings instead of KSB signal blue. - - Generated by generate_pump_assets.py from the /Plant/Pumps/P101 master in - Plant.usda. Do not edit by hand - edit P101 and re-run the generator.""" - defaultPrim = "Pump" - metersPerUnit = 1 - upAxis = "Z" -) - -def Xform "Pump" ( - kind = "component" -) -{ - def Scope "Looks" - { - def Material "PumpBlue" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.24, 0.36, 0.20) - float inputs:metallic = 0.1 - float inputs:roughness = 0.45 - token outputs:surface - } - } - - def Material "MotorGrey" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.60, 0.61, 0.60) - float inputs:metallic = 0.2 - float inputs:roughness = 0.50 - token outputs:surface - } - } - - def Material "Galvanised" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.68, 0.70, 0.72) - float inputs:metallic = 0.8 - float inputs:roughness = 0.35 - token outputs:surface - } - } - - def Material "DarkSteel" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.16, 0.17, 0.19) - float inputs:metallic = 0.6 - float inputs:roughness = 0.55 - token outputs:surface - } - } - - def Material "Bronze" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.72, 0.53, 0.24) - float inputs:metallic = 0.9 - float inputs:roughness = 0.30 - token outputs:surface - } - } - } - - def Xform "Baseplate" - { - def Cube "Plate" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] - rel material:binding = - double3 xformOp:translate = (-0.08, 0, 0.06) - double3 xformOp:scale = (1.80, 0.46, 0.12) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "PumpPad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] - rel material:binding = - double3 xformOp:translate = (-0.62, 0, 0.135) - double3 xformOp:scale = (0.44, 0.40, 0.03) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "MotorRailA" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] - rel material:binding = - double3 xformOp:translate = (0.31, -0.105, 0.1365) - double3 xformOp:scale = (0.62, 0.07, 0.033) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "MotorRailB" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.26, 0.27, 0.29)] - rel material:binding = - double3 xformOp:translate = (0.31, 0.105, 0.1365) - double3 xformOp:scale = (0.62, 0.07, 0.033) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "LiftingLugA" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] - rel material:binding = - double3 xformOp:translate = (-0.86, -0.24, 0.10) - double3 xformOp:scale = (0.10, 0.014, 0.05) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "LiftingLugB" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.24, 0.25, 0.27)] - rel material:binding = - double3 xformOp:translate = (0.70, 0.24, 0.10) - double3 xformOp:scale = (0.10, 0.014, 0.05) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - } - - def Cylinder "Body" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.110 - double radius = 0.1775 - color3f[] primvars:displayColor = [(0.24, 0.36, 0.20)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - - def Material "Mat" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.24, 0.36, 0.20) - float inputs:metallic = 0.1 - float inputs:roughness = 0.45 - token outputs:surface - } - } - } - - def Xform "Casing" - { - def Cylinder "VoluteShoulder" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.070 - double radius = 0.196 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0.014, 0.293) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "Foot" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0, 0.1875) - double3 xformOp:scale = (0.11, 0.30, 0.135) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "FootPad" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0, 0.159) - double3 xformOp:scale = (0.16, 0.36, 0.018) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cylinder "DrainPlug" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.022 - double radius = 0.016 - color3f[] primvars:displayColor = [(0.72, 0.53, 0.24)] - rel material:binding = - double3 xformOp:translate = (-0.700, 0, 0.115) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - - def Xform "Suction" - { - def Cylinder "Flange" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.020 - double radius = 0.100 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.860, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Neck" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.055 - double radius = 0.051 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.822, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltTop" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.034 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.860, 0, 0.360) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltBottom" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.034 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.860, 0, 0.200) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.034 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.860, -0.080, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.034 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.860, 0.080, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - - def Xform "Discharge" - { - def Cylinder "Neck" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.150 - double radius = 0.043 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0, 0.455) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Flange" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.018 - double radius = 0.0925 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0, 0.539) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltFront" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.032 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.5725, 0, 0.539) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltBack" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.032 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.7175, 0, 0.539) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.032 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0.0725, 0.539) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "BoltRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.032 - double radius = 0.009 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.645, -0.0725, 0.539) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Xform "Gauge" - { - def Cylinder "Stem" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Y" - double height = 0.055 - double radius = 0.007 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.58)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0.070, 0.470) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Dial" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Y" - double height = 0.022 - double radius = 0.041 - color3f[] primvars:displayColor = [(0.90, 0.90, 0.88)] - rel material:binding = - double3 xformOp:translate = (-0.645, 0.108, 0.470) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - } - - def Xform "PowerEnd" - { - def Cylinder "SealHousing" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.060 - double radius = 0.046 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.560, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Lantern" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.040 - double radius = 0.058 - color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] - rel material:binding = - double3 xformOp:translate = (-0.510, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Bracket" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.220 - double radius = 0.0625 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (-0.380, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "BracketFoot" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.20, 0.30, 0.16)] - rel material:binding = - double3 xformOp:translate = (-0.360, 0, 0.1875) - double3 xformOp:scale = (0.16, 0.20, 0.105) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cylinder "SightGlass" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Y" - double height = 0.020 - double radius = 0.015 - color3f[] primvars:displayColor = [(0.85, 0.86, 0.62)] - rel material:binding = - double3 xformOp:translate = (-0.330, 0.064, 0.245) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - } - - def Cylinder "Bearing" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.036 - double radius = 0.052 - color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] - rel material:binding = - double3 xformOp:translate = (-0.300, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Xform "Impeller" - { - double3 xformOp:translate = (0, 0, 0.28) - double xformOp:rotateY = 90 - double xformOp:rotateZ = 0 - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateY", "xformOp:rotateZ" - ] - - custom double inputs:speedSetpoint = 0 - - def Cylinder "Shaft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.760 - double radius = 0.019 - color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] - rel material:binding = - double3 xformOp:translate = (0, 0, -0.300) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Shroud" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.012 - double radius = 0.099 - color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.664) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "Hub" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.034 - double radius = 0.030 - color3f[] primvars:displayColor = [(0.66, 0.48, 0.22)] - rel material:binding = - double3 xformOp:translate = (0, 0, 0.645) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "VaneA" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (0.052, 0.014, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 22) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "VaneB" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (0.014, 0.052, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 82) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "VaneC" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (-0.038, 0.038, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 142) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "VaneD" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (-0.052, -0.014, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 202) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "VaneE" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (-0.014, -0.052, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 262) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "VaneF" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.70, 0.52, 0.24)] - rel material:binding = - double3 xformOp:translate = (0.038, -0.038, 0.646) - double3 xformOp:rotateXYZ = (0, 0, 322) - double3 xformOp:scale = (0.086, 0.010, 0.026) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cylinder "CouplingHubPump" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.052 - double radius = 0.070 - color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] - rel material:binding = - double3 xformOp:translate = (0, 0, -0.196) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "CouplingSpider" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.014 - double radius = 0.058 - color3f[] primvars:displayColor = [(0.92, 0.66, 0.06)] - rel material:binding = - double3 xformOp:translate = (0, 0, -0.163) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "CouplingHubMotor" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.052 - double radius = 0.070 - color3f[] primvars:displayColor = [(0.34, 0.35, 0.37)] - rel material:binding = - double3 xformOp:translate = (0, 0, -0.130) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "CouplingKey" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.94, 0.94, 0.95)] - rel material:binding = - double3 xformOp:translate = (0, 0, -0.168) - double3 xformOp:scale = (0.020, 0.140, 0.010) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - } - - def Xform "CouplingGuard" - { - def Cylinder "RingPump" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.012 - double radius = 0.110 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.264, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "RingMotor" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.012 - double radius = 0.110 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.006, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "BarTop" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.135, 0, 0.388) - double3 xformOp:scale = (0.264, 0.030, 0.008) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "BarBottom" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.135, 0, 0.172) - double3 xformOp:scale = (0.264, 0.030, 0.008) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "BarLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.135, -0.108, 0.28) - double3 xformOp:scale = (0.264, 0.008, 0.030) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "BarRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.66, 0.68, 0.70)] - rel material:binding = - double3 xformOp:translate = (-0.135, 0.108, 0.28) - double3 xformOp:scale = (0.264, 0.008, 0.030) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - } - - def Xform "Motor" - { - def Cylinder "Frame" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.615 - double radius = 0.127 - color3f[] primvars:displayColor = [(0.60, 0.61, 0.60)] - rel material:binding = - double3 xformOp:translate = (0.3125, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "DriveEndShield" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.050 - double radius = 0.132 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] - rel material:binding = - double3 xformOp:translate = (0.030, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "NonDriveEndShield" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.050 - double radius = 0.132 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] - rel material:binding = - double3 xformOp:translate = (0.595, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "FanCowl" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.080 - double radius = 0.140 - color3f[] primvars:displayColor = [(0.48, 0.49, 0.50)] - rel material:binding = - double3 xformOp:translate = (0.660, 0, 0.28) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "FinTop" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] - rel material:binding = - double3 xformOp:translate = (0.3125, 0, 0.412) - double3 xformOp:scale = (0.615, 0.030, 0.024) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "FinUpperLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] - rel material:binding = - double3 xformOp:translate = (0.3125, -0.098, 0.372) - double3 xformOp:rotateXYZ = (40, 0, 0) - double3 xformOp:scale = (0.615, 0.024, 0.030) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "FinUpperRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] - rel material:binding = - double3 xformOp:translate = (0.3125, 0.098, 0.372) - double3 xformOp:rotateXYZ = (-40, 0, 0) - double3 xformOp:scale = (0.615, 0.024, 0.030) - uniform token[] xformOpOrder = [ - "xformOp:translate", "xformOp:rotateXYZ", "xformOp:scale" - ] - } - - def Cube "FinLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] - rel material:binding = - double3 xformOp:translate = (0.3125, -0.132, 0.28) - double3 xformOp:scale = (0.615, 0.024, 0.030) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "FinRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.56, 0.57, 0.56)] - rel material:binding = - double3 xformOp:translate = (0.3125, 0.132, 0.28) - double3 xformOp:scale = (0.615, 0.024, 0.030) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "TerminalBox" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.52, 0.53, 0.52)] - rel material:binding = - double3 xformOp:translate = (0.230, 0, 0.452) - double3 xformOp:scale = (0.120, 0.120, 0.080) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cylinder "CableGland" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Y" - double height = 0.040 - double radius = 0.016 - color3f[] primvars:displayColor = [(0.30, 0.31, 0.33)] - rel material:binding = - double3 xformOp:translate = (0.230, -0.078, 0.440) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "EyeBolt" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "Z" - double height = 0.046 - double radius = 0.012 - color3f[] primvars:displayColor = [(0.62, 0.63, 0.65)] - rel material:binding = - double3 xformOp:translate = (0.470, 0, 0.428) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cube "Nameplate" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.86, 0.87, 0.88)] - rel material:binding = - double3 xformOp:translate = (0.150, -0.129, 0.318) - double3 xformOp:scale = (0.090, 0.004, 0.055) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "FootLeft" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] - rel material:binding = - double3 xformOp:translate = (0.310, -0.105, 0.179) - double3 xformOp:scale = (0.300, 0.044, 0.052) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - - def Cube "FootRight" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double size = 1.0 - color3f[] primvars:displayColor = [(0.55, 0.56, 0.55)] - rel material:binding = - double3 xformOp:translate = (0.310, 0.105, 0.179) - double3 xformOp:scale = (0.300, 0.044, 0.052) - uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] - } - } -} diff --git a/samples/PumpDeviceIntegrationServer/DATASHEET.md b/samples/PumpDeviceIntegrationServer/DATASHEET.md new file mode 100644 index 0000000000..d94f15ea67 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/DATASHEET.md @@ -0,0 +1,324 @@ +# PumpX-2000 — Product Datasheet + + + + +
SimPump Corp
Single-stage end-suction centrifugal process pump
+Document no. SPC-DS-PX2000
+Revision 3  ·  Issue date 2025-04-17
+Supersedes revision 2 (2024-11-05) +
+ +> **Simulated device.** The PumpX-2000 is a *fictitious* product used by the +> [`PumpDeviceIntegrationServer`](./README.md) sample of the OPC UA .NET Standard +> stack. It does not exist as hardware. Every figure in this datasheet is +> reproduced by the sample server's simulation and is asserted by +> `tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs`, so this document and +> the running address space cannot drift apart. + +--- + +## 1 Product description + +The PumpX-2000 is a single-stage, end-suction, radially split centrifugal pump +for clean and lightly contaminated liquids in utility and process-water duty. +The hydraulic end is close-coupled to a foot-mounted asynchronous motor; the +bearing bracket carries a temperature probe and the discharge nozzle carries a +combined pressure/flow transmitter assembly. + +The pump is instrumented as an OPC UA asset. It exposes its nameplate, its +process values and its supervision states through the +[OPC 40223 Pumps](https://reference.opcfoundation.org/specs/OPC-40223) companion +specification, layered on +[OPC 40001-1 Machinery](https://reference.opcfoundation.org/Machinery/v102/docs/) +and [OPC 10000-100 Device Integration](https://reference.opcfoundation.org/DI/v104/docs/). + +| Item | Value | +|---|---| +| Type designation | PumpX-2000 | +| Product code | PX2000-32-160 | +| Construction | Single-stage, end-suction, radially split | +| Impeller | Closed, 160 mm nominal diameter | +| Service | Clean and lightly contaminated liquids, pH 5–10 | +| Mounting | Horizontal, foot-mounted baseplate | +| Companion specification | OPC 40223 `PumpType` | + +--- + +## 2 Nameplate and identification data + +Every nameplate field is published as a property of the pump's `Identification` +functional group. Browse names are listed with their namespace; the namespace +prefixes below map to `http://opcfoundation.org/UA/DI/` (DI), +`http://opcfoundation.org/UA/Machinery/` (Machinery) and +`http://opcfoundation.org/UA/Pumps/` (Pumps). + +| Property | Namespace | DataType | Unit SN-001 | Unit SN-002 | +|---|---|---|---|---| +| `Manufacturer` | DI | `LocalizedText` | SimPump Corp | SimPump Corp | +| `ManufacturerUri` | DI | `String` | `https://simpump.example` | `https://simpump.example` | +| `Model` | DI | `LocalizedText` | PumpX-2000 | PumpX-2000 | +| `ProductCode` | DI | `String` | PX2000-32-160 | PX2000-32-160 | +| `DeviceClass` | DI | `String` | Pump | Pump | +| `HardwareRevision` | DI | `String` | 1.4 | 1.4 | +| `SoftwareRevision` | DI | `String` | 2.5.3 | 2.5.3 | +| `SerialNumber` | DI | `String` | SN-001 | SN-002 | +| `ProductInstanceUri` | DI | `String` | `urn:simdevice:SimPump:PumpX-2000:SN-001` | `urn:simdevice:SimPump:PumpX-2000:SN-002` | +| `AssetId` | DI | `String` | PMP-1001 | PMP-1002 | +| `ComponentName` | DI | `LocalizedText` | Feed Pump A | Feed Pump B | +| `Location` | Machinery | `String` | Plant 1 / Utility Skid / Bay 3 | Plant 1 / Utility Skid / Bay 4 | +| `YearOfConstruction` | Machinery | `UInt16` | 2025 | 2025 | +| `MonthOfConstruction` | Machinery | `Byte` | 4 | 4 | +| `DayOfConstruction` | Pumps | `Int32` | 17 | 17 | +| `ArticleNumber` | Pumps | `String` | PX2000-32-160-CI | PX2000-32-160-CI | +| `OrderProductCode` | Pumps | `String` | PX2000-32-160-CI-M30 | PX2000-32-160-CI-M30 | +| `TypeOfProduct` | Pumps | `String` | Centrifugal pump, end-suction | Centrifugal pump, end-suction | +| `Supplier` | Pumps | `String` | SimPump Corp | SimPump Corp | +| `CountryOfOrigin` | Pumps | `String` | DE | DE | +| `FabricationNumber` | Pumps | `String` | F-2025-0001 | F-2025-0002 | + +Both units are the same product; they differ only in serial number, fabrication +number, asset identifier, component name and installation bay. The sample +server materialises two units by default and derives the same per-unit fields +for any further unit when started with `--pumps N` (`SN-00n`, `PMP-100n`, +`Feed Pump A/B/…`, `F-2025-000n`, `Bay n+2`). + +--- + +## 3 Hydraulic performance + +### 3.1 Design point + +Reference liquid: water at 20 °C, density ρ = 998 kg/m³, ν = 1.0 mm²/s. + +| Quantity | Symbol | Value | +|---|---|---| +| Rated flow (best efficiency point) | QBEP | 25.0 m³/h (6.93 kg/s) | +| Rated head | HBEP | 25.5 m | +| Rated differential pressure | ΔpBEP | 249.7 kPa (2.50 bar) | +| Rated efficiency | ηBEP | 72.0 % | +| Hydraulic power | Phyd | 1.73 kW | +| Rated shaft power | P2 | 2.41 kW | +| Shut-off head | H0 | 32.0 m | +| NPSH required at BEP | NPSHR | 2.4 m | +| Rated speed | n | 2900 min-1 | + +### 3.2 Characteristic curves + +The pump is characterised by a quadratic head curve and a parabolic efficiency +curve about the best efficiency point (Q in m³/h): + +```text +H(Q) = 32.0 − 0.0104 · Q² [m] +η(Q) = 72.0 · (1 − 0.6 · ((Q − 25) / 25)²) [%] +Δp(Q) = ρ · g · H(Q) [Pa] +ṁ(Q) = ρ · Q / 3600 [kg/s] +P₂(Q) = ρ · g · (Q / 3600) · H(Q) / (η(Q)/100) [W] +``` + +### 3.3 Performance table + +| Flow Q [m³/h] | Mass flow ṁ [kg/s] | Head H [m] | Δp [kPa] | Efficiency η [%] | Shaft power P₂ [kW] | +|---:|---:|---:|---:|---:|---:| +| 10 | 2.77 | 30.96 | 303.1 | 56.4 | 1.49 | +| 15 | 4.16 | 29.66 | 290.4 | 65.1 | 1.86 | +| 20 | 5.54 | 27.84 | 272.6 | 70.3 | 2.15 | +| **25** | **6.93** | **25.50** | **249.7** | **72.0** | **2.41** | +| 30 | 8.32 | 22.64 | 221.7 | 70.3 | 2.63 | +| 35 | 9.70 | 19.26 | 188.6 | 65.1 | 2.82 | + +Permissible continuous operating window: 0.5 · QBEP … 1.3 · QBEP +(12.5 … 32.5 m³/h). + +### 3.4 Process schematic and instrument tags + +```mermaid +flowchart LR + V[("Suction vessel
V-101")] + LT(["LT-101
Level"]) + S1[/"Suction nozzle
DN 50"/] + P(["P-101
PumpX-2000"]) + M["M-101
3.0 kW motor"] + TT(["TT-102
Bearing temp."]) + TT1(["TT-101
Fluid temp."]) + PT(["PDT-101
Differential pressure"]) + FT(["FT-101
Mass flow"]) + JE(["JE-101
Power / efficiency"]) + D[/"Discharge nozzle
DN 32"/] + H[("Process header")] + + V --> LT --> S1 --> P --> D --> FT --> H + M -.drive.-> P + TT -.bearing bracket.-> P + TT1 -.suction line.-> S1 + PT -.suction vs. discharge.-> D + JE -.motor terminals.-> M + + classDef sensor fill:#eef6ff,stroke:#4a76a8,stroke-width:1px; + class LT,TT,TT1,PT,FT,JE sensor; +``` + +| Tag | Measurand | OPC UA browse path (relative to the pump) | Unit | +|---|---|---|---| +| PDT-101 | Differential pressure | `Operational/Measurements/DifferentialPressure` | Pa | +| TT-101 | Fluid temperature | `Operational/Measurements/FluidTemperature` | K | +| TT-102 | Bearing temperature | `Operational/Measurements/BearingTemperature` | K | +| JE-101 | Shaft power input | `Operational/Measurements/PumpPowerInput` | W | +| FT-101 | Mass flow | `Operational/Measurements/MassFlow` | kg/s | +| JE-101 | Pump efficiency | `Operational/Measurements/PumpEfficiency` | % | +| LT-101 | Suction vessel level | `Operational/Measurements/Level` | m | +| — | Start counter | `Operational/Measurements/NumberOfStarts` | – | + +--- + +## 4 Operating limits + +Each limit is published as the `EURange` property of the corresponding +measurement variable, and each measurement carries the `EngineeringUnits` +property with the UNECE unit code shown below. + +| Measurement | Engineering unit | `EURange` low | `EURange` high | Nominal | +|---|---|---:|---:|---:| +| Differential pressure | Pa (Pascal) | 0 | 400 000 | 249 655 | +| Fluid temperature | K (Kelvin) | 263.15 (−10 °C) | 393.15 (120 °C) | 313.15 (40 °C) | +| Bearing temperature | K (Kelvin) | 273.15 (0 °C) | 423.15 (150 °C) | 333.15 (60 °C) | +| Shaft power input | W (Watt) | 0 | 4 000 | 2 408 | +| Mass flow | kg/s | 0 | 10 | 6.93 | +| Pump efficiency | % | 0 | 100 | 72 | +| Suction vessel level | m (Metre) | 0 | 5 | 2.5 | + +| Additional limit | Value | +|---|---| +| Maximum casing working pressure | 10 bar | +| Maximum permissible starts per hour | 15 | +| Minimum continuous flow | 12.5 m³/h | +| Permissible ambient temperature | −10 … 40 °C | +| Sound pressure level at 1 m | ≤ 72 dB(A) | + +--- + +## 5 Motor and electrical data + +| Item | Value | +|---|---| +| Rated power | 3.0 kW | +| Supply | 400 V, 3~, 50 Hz | +| Rated speed | 2900 min-1 | +| Rated current | 6.1 A | +| Efficiency class | IE3 | +| Insulation / protection | Class F / IP55 | +| Duty type | S1 (continuous) | + +--- + +## 6 Materials and connections + +| Component | Material | +|---|---| +| Casing | Cast iron EN-GJL-250 | +| Impeller | Bronze CuSn10 | +| Shaft | Stainless steel 1.4021 | +| Shaft seal | Mechanical seal, SiC/carbon/EPDM | +| Bearings | Grease-lubricated deep-groove ball bearings | + +| Connection | Size | Standard | +|---|---|---| +| Suction nozzle | DN 50, PN 16 | EN 1092-2 | +| Discharge nozzle | DN 32, PN 16 | EN 1092-2 | + +| Dimension | Value | +|---|---| +| Length × width × height | 620 × 240 × 380 mm | +| Mass (pump with motor) | 68 kg | + +--- + +## 7 Monitoring, supervision and alarms + +### 7.1 Trip points + +The bearing-temperature chain is monitored by a `NonExclusiveLimitAlarmType` +instance published at `Events/OverTempAlarm`. Its `SourceNode` is the +`BearingTemperature` variable and its limits are the datasheet trip points: + +| Limit | Value | Equivalent | Action | +|---|---:|---|---| +| `HighHighLimit` | 373.15 K | 100 °C | Trip — stop the pump | +| `HighLimit` | 363.15 K | 90 °C | Alarm — reduce load, check cooling | +| `LowLimit` | 283.15 K | 10 °C | Warning — lubricant below operating viscosity | +| `LowLowLimit` | 278.15 K | 5 °C | Warning — risk of freezing | + +The alarm is acknowledgeable; the sample accepts every acknowledge request. + +### 7.2 Supervision states (NAMUR-style) + +| Supervision variable | Browse path | Set condition | Reset condition | +|---|---|---|---| +| Motor overheat | `Events/SupervisionPumpOperation/MotorOverheat` | Bearing temperature ≥ 363.15 K (90 °C) | Bearing temperature < 361.15 K (88 °C) | +| Cavitation | `Events/SupervisionProcessFluid/Cavitation` | Suction level < 2.10 m (NPSHA < NPSHR) | Suction level > 2.20 m | + +`MotorOverheat` drives the activation of `OverTempAlarm`; the resulting +condition events are delivered through the `HasNotifier` chain from the pump to +the `Server` object. + +--- + +## 8 Simulation profile + +The sample server reproduces the datasheet with a deterministic model driven by +a single independent variable — volumetric flow. Every other published value is +derived from the characteristic curves in section 3.2, so the published values +are mutually consistent at all times. The simulation advances on one shared +250 ms tick; each pump uses a fixed phase offset of 17 ticks per instance so the +units never move in lockstep. + +| Signal | Model | Simulated range | +|---|---|---| +| Flow | `Q(t) = 25 · (1 + 0.30 · sin(0.03 · t))` | 17.50 … 32.50 m³/h | +| Differential pressure | `ρ · g · H(Q)` | 205.7 … 282.1 kPa | +| Mass flow | `ρ · Q / 3600` | 4.85 … 9.01 kg/s | +| Efficiency | `η(Q)` | 68.1 … 72.0 % | +| Shaft power | `ρ · g · (Q/3600) · H(Q) / η` | 2.01 … 2.73 kW | +| Bearing temperature | `323.15 + 10 · (P₂/P₂,BEP) + cooling-fault excursion` | 331.5 … 378.2 K (58.4 … 105.1 °C) | +| Fluid temperature | `313.15 + 5 · sin(0.01 · t)` | 308.15 … 318.15 K (35 … 45 °C) | +| Suction level | `2.5 + 0.5 · sin(0.02 · t)` | 2.00 … 3.00 m | +| Start counter | one start per 15 simulated minutes | monotonic | + +*Cooling-fault excursion*: every 64 ticks (16 s) the simulated bearing-cooling +water is interrupted for the last 8 ticks (2 s), ramping the bearing temperature +linearly by up to +43.75 K. The excursion crosses both the `HighLimit` and the +`HighHighLimit` trip points, so a full alarm activate/clear cycle — including the +`MotorOverheat` supervision transition — is observable roughly every 16 seconds. + +*Cavitation*: the suction level sine dips below the 2.10 m NPSH threshold once +per level cycle (≈ 78 s). Because the state only clears again above 2.20 m, the +`Cavitation` supervision state is held for approximately 19.6 s (78 ticks) per +cycle. + +--- + +## 9 Standards and references + +| Reference | Title | +|---|---| +| OPC 40223 | OPC UA for Pumps and Vacuum Pumps — Part 1: Pumps | +| OPC 40001-1 | OPC UA for Machinery — Part 1: Basic Building Blocks | +| OPC 10000-100 | OPC UA Part 100: Devices | +| EN ISO 9906 | Rotodynamic pumps — Hydraulic performance acceptance tests, grade 2B | +| EN 1092-2 | Flanges and their joints — Cast iron flanges | +| IEC 60034-30-1 | Rotating electrical machines — Efficiency classes (IE3) | + +--- + +## 10 Document history + +| Revision | Date | Change | +|---|---|---| +| 3 | 2025-04-17 | Bearing-temperature trip points aligned with IE3 motor package; simulation profile section added. | +| 2 | 2024-11-05 | NPSHR corrected to 2.4 m at BEP. | +| 1 | 2024-03-12 | First issue. | + +**Disclaimer** — SimPump Corp, the PumpX-2000 and all identifiers in this +document are fictitious and exist only to give the +[`PumpDeviceIntegrationServer`](./README.md) sample a realistic asset to +publish. Do not use these figures for engineering purposes. diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs index 02564eb07b..2c88fc09f9 100644 --- a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs +++ b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs @@ -56,24 +56,112 @@ public partial class PumpNodeManager // 1:1 (Child): create Impeller + Bearing component Objects on the pump, each // with its own representation, and declare One bindings. - private void AttachPumpComponents(PumpState pump, OpenUsdRepresentationState pumpRep, ushort ns) + private void AttachPumpComponents( + PumpState pump, OpenUsdRepresentationState pumpRep, ushort ns, string primPath) { (BaseObjectState _, OpenUsdRepresentationState impellerRep) = CreateRepresentedComponent( - pump, "Impeller", pump.BrowseName.NamespaceIndex, "/Plant/Pumps/P101/Impeller", ns); + pump, "Impeller", pump.BrowseName.NamespaceIndex, primPath + "/Impeller", ns); (BaseObjectState _, OpenUsdRepresentationState bearingRep) = CreateRepresentedComponent( - pump, "Bearing", pump.BrowseName.NamespaceIndex, "/Plant/Pumps/P101/Bearing", ns); + pump, "Bearing", pump.BrowseName.NamespaceIndex, primPath + "/Bearing", ns); CreateComponentBinding(pumpRep, ns, "ImpellerComponent", - new Guid("a1b2c3d4-0001-4000-8000-000000000001"), + GuidFor("ImpellerComponent"), OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, - "/Plant/Pumps/P101/Impeller", componentRepresentation: impellerRep.NodeId); + primPath + "/Impeller", componentRepresentation: impellerRep.NodeId); CreateComponentBinding(pumpRep, ns, "BearingComponent", - new Guid("a1b2c3d4-0001-4000-8000-000000000002"), + GuidFor("BearingComponent"), OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, - "/Plant/Pumps/P101/Bearing", componentRepresentation: bearingRep.NodeId); + primPath + "/Bearing", componentRepresentation: bearingRep.NodeId); } - // 1..n + dynamic + cross-server: a ProductionLine aggregating pumps. + /// + /// Composes one full-fidelity pump prim per configured pump. + /// + /// + /// + /// The DeviceSet carries a plant-level representation anchored on + /// /Plant with a single Many component binding scoped to + /// PumpType. A connector resolves that binding against the + /// DeviceSet's children and composes + /// /Plant/Pumps/<BrowseName> for each pump from the served + /// pump.usda component asset, so the rendered scene follows + /// --pumps N without the stage having to author anything per pump. + /// + /// + /// The arc is Reference, not Instance: an instanceable prim + /// turns its descendants into a shared prototype, and a shared prototype + /// cannot carry the per-pump impeller rotation, casing colour or gauge + /// needles that make each machine read as its own. + /// + /// + private async ValueTask MaterialisePlantAggregationAsync( + CancellationToken cancellationToken) + { + if (m_plantStage == null) + { + return; + } + try + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); + NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( + Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); + if (deviceSet == null) + { + return; + } + + OpenUsdRepresentationState plantRep = SystemContext + .CreateInstanceOfOpenUsdRepresentationType( + deviceSet, new QualifiedName("OpenUsdRepresentation", ns)); + plantRep.ReferenceTypeId = ReferenceTypeIds.HasComponent; + deviceSet.AddChild(plantRep); + plantRep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, plantRep); + plantRep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage.NodeId; + plantRep.CreateOrReplacePrimPath(SystemContext, null!).Value = PlantPrimPath; + + NodeId pumpTypeId = NodeId.Create( + Opc.Ua.Pumps.ObjectTypes.PumpType, + Opc.Ua.Pumps.Namespaces.Pumps, + Server.NamespaceUris); + // Not dynamic: the configured pump set is fixed by --pumps N at + // start-up, and the dynamic add/remove path is already demonstrated + // by the ProductionLine. Declaring it dynamic here would also make + // the connector's stale-prim reconciliation sweep every prim under + // /Plant/Pumps — including the Impeller and Bearing component prims + // this scope now contains — and deactivate them. + CreateComponentBinding(plantRep, ns, "ConfiguredPumps", + new Guid("a1b2c3d4-0004-4000-8000-000000000001"), + OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Reference, + "Pumps", + assetReference: "@pump.usda@", + componentTypeDefinition: pumpTypeId); + + SystemContext.AssignInstanceChildNodeIds(plantRep); + await AddPredefinedNodeAsync(SystemContext, plantRep, cancellationToken) + .ConfigureAwait(false); + + FolderState? registry = m_openUsdRoot?.Representations; + if (registry != null) + { + registry.AddReference(ReferenceTypeIds.Organizes, false, plantRep.NodeId); + plantRep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); + } + + m_logger.MaterialisedPlantAggregation(m_twins.Count); + } + catch (Exception ex) + { + m_logger.LogError(ex, "Failed to materialise the plant aggregation."); + } + } + + // 1..n + dynamic aggregation: a ProductionLine aggregating pumps. The + // aggregation is an address-space demo only - it carries no OpenUSD + // representation, so the rendered hall holds exactly the pumps the + // connected server simulates and nothing else. A line pump is a static + // topology entry, not a machine anyone is driving; rendering it put + // phantom pumps in the twin that no client could account for. private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancellationToken) { if (m_plantStage == null) @@ -101,14 +189,6 @@ private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancell deviceSet.AddChild(line); line.NodeId = SystemContext.NodeIdFactory.New(SystemContext, line); - OpenUsdRepresentationState lineRep = SystemContext.CreateInstanceOfOpenUsdRepresentationType( - line, new QualifiedName("OpenUsdRepresentation", ns)); - lineRep.ReferenceTypeId = ReferenceTypeIds.HasComponent; - line.AddChild(lineRep); - lineRep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, lineRep); - lineRep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage.NodeId; - lineRep.CreateOrReplacePrimPath(SystemContext, null!).Value = LinePrimPath; - var pumps = new FolderState(line) { SymbolicName = "Pumps", @@ -121,39 +201,13 @@ private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancell pumps.NodeId = SystemContext.NodeIdFactory.New(SystemContext, pumps); m_linePumps = pumps; - // Two static aggregated pumps (1..n baseline). - CreateRepresentedComponent(pumps, "P-201", ns, - LinePrimPath + "/Pumps/P_201", ns, ReferenceTypeIds.Organizes); - CreateRepresentedComponent(pumps, "P-202", ns, - LinePrimPath + "/Pumps/P_202", ns, ReferenceTypeIds.Organizes); - - // Many : aggregate the Pumps as instanceable references, dynamic. - CreateComponentBinding(lineRep, ns, "PumpsAggregation", - new Guid("a1b2c3d4-0002-4000-8000-000000000001"), - OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Instance, - LinePrimPath + "/Pumps", - assetReference: "@pump.usda@", dynamic: true, - changeEventSource: Opc.Ua.ObjectIds.Server); - - // Cross-server : an OEM pump on another server (federation). - CreateComponentBinding(lineRep, ns, "RemotePumpComponent", - new Guid("a1b2c3d4-0003-4000-8000-000000000001"), - OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Reference, - LinePrimPath + "/RemotePump", - assetReference: "@remote-pump.usda@", - componentServerUri: RemoteServerUri, componentEndpointUrl: RemoteEndpointUrl); + // Two static aggregated entries (1..n baseline). + CreateAggregatedPump(pumps, "P-201", ns); + CreateAggregatedPump(pumps, "P-202", ns); SystemContext.AssignInstanceChildNodeIds(line); await AddPredefinedNodeAsync(SystemContext, line, cancellationToken).ConfigureAwait(false); - // Register the line representation in the discovery registry. - FolderState? registry = m_openUsdRoot?.Representations; - if (registry != null) - { - registry.AddReference(ReferenceTypeIds.Organizes, false, lineRep.NodeId); - lineRep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); - } - // Dynamic composition: emit model-change events on runtime add/remove. ModelChangeEmissionEnabled = true; _ = RunDynamicCompositionAsync(ns); @@ -167,11 +221,21 @@ private async ValueTask MaterialiseProductionLineAsync(CancellationToken cancell } } - // The cross-server component points at this same server process (a loopback - // stand-in for an OEM sub-asset server); a test overrides RemoteEndpointUrl. - private static string RemoteServerUri => "urn:localhost:OPCFoundation:PumpDeviceIntegrationServer"; - private string RemoteEndpointUrl { get; set; } = - "opc.tcp://localhost:62810/PumpDeviceIntegrationServer"; + // An aggregated line entry: a plain topology Object with no + // representation, because it is not a machine the server simulates. + private void CreateAggregatedPump(NodeState parent, string name, ushort ns) + { + var obj = new BaseObjectState(parent) + { + SymbolicName = name, + BrowseName = new QualifiedName(name, ns), + DisplayName = new LocalizedText(name), + ReferenceTypeId = ReferenceTypeIds.Organizes, + TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType + }; + parent.AddChild(obj); + obj.NodeId = SystemContext.NodeIdFactory.New(SystemContext, obj); + } // Dynamic demo (§5.13): repeatedly add a pump (emits a GeneralModelChange), // hold, then remove it (emits again), so a connector observes both the add and @@ -288,5 +352,10 @@ internal static partial class OpenUsdCompositionLog Level = LogLevel.Information, Message = "Dynamic composition: added line pump '{Name}' (NodeId={NodeId}); model-change emitted.")] public static partial void AddedLinePump(this ILogger logger, string name, NodeId nodeId); + + [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 3, + Level = LogLevel.Information, + Message = "Plant aggregation composes {PumpCount} configured pump(s) from pump.usda.")] + public static partial void MaterialisedPlantAggregation(this ILogger logger, int pumpCount); } } diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs index 8ded3ce907..b970e677a0 100644 --- a/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs +++ b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Threading; @@ -50,9 +51,76 @@ public partial class PumpNodeManager { private OpenUsdRootState? m_openUsdRoot; private OpenUsdStageState? m_plantStage; - private BaseDataVariableState? m_alarmActiveVar; - private BaseDataVariableState? m_speedSetpointVar; - private BaseDataVariableState? m_shaftAngleVar; + + /// + /// 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 @@ -68,11 +136,27 @@ public partial class PumpNodeManager /// and read correctly but are never sampled, which leaves any binding that /// uses them frozen at its start-up value. /// - private async ValueTask RegisterOpenUsdSignalsAsync(CancellationToken cancellationToken) + /// 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[] { - m_alarmActiveVar, m_shaftAngleVar, m_speedSetpointVar + twin.AlarmActive, twin.ShaftAngle, twin.SpeedSetpoint, + twin.BayPosition, twin.FluidSurface }) { if (signal != null) @@ -81,17 +165,44 @@ 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. - if (m_shaftAngleVar != null) + // data-change notifications. The closure captures this pump's twin, + // so every pump reports its own angle. + if (twin.ShaftAngle != null) { - TrackSignal(m_shaftAngleVar, () => ShaftAngleDegrees); + TrackSignal(twin.ShaftAngle, () => twin.ShaftAngleDegrees); } } private const string PlantRootLayerIdentifier = "asset-repo/Plant.usd"; - private const string PumpPrimPath = "/Plant/Pumps/P101"; + + /// + /// 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) @@ -172,7 +283,6 @@ private static List LoadServedAssets() { new ServedAsset("Plant.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Plant.usda")), new ServedAsset("pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("pump.usda")), - new ServedAsset("remote-pump.usda", OpenUsdAssetKindEnum.Reference, ReadEmbeddedAsset("remote-pump.usda")), }; } @@ -227,13 +337,15 @@ private void LinkOpenUsdRootToServer( // 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) + 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 .CreateInstanceOfOpenUsdRepresentationType( @@ -245,25 +357,52 @@ private void AttachOpenUsdRepresentation(PumpState pump) rep.NodeId = SystemContext.NodeIdFactory.New(SystemContext, rep); rep.CreateOrReplaceStage(SystemContext, null!).Value = m_plantStage.NodeId; - rep.CreateOrReplacePrimPath(SystemContext, null!).Value = PumpPrimPath; + rep.CreateOrReplacePrimPath(SystemContext, null!).Value = primPath; + 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). - m_alarmActiveVar = CreatePumpVariable( + 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. - m_alarmActiveVar.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; - m_alarmActiveVar.OnSimpleReadValue = + twin.AlarmActive.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; + PumpTwin alarmTwin = twin; + twin.AlarmActive.OnSimpleReadValue = (ISystemContext context, NodeState node, ref Variant value) => { - value = new Variant(AlarmActive); + value = new Variant(alarmTwin.AlarmActiveState); return ServiceResult.Good; }; @@ -271,13 +410,14 @@ private void AttachOpenUsdRepresentation(PumpState pump) // 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. - m_shaftAngleVar = CreatePumpVariable( + twin.ShaftAngle = CreatePumpVariable( pump, "ShaftAngle", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: false); - m_shaftAngleVar.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; - m_shaftAngleVar.OnSimpleReadValue = + twin.ShaftAngle.MinimumSamplingInterval = SignalSamplingIntervalMilliseconds; + PumpTwin shaftTwin = twin; + twin.ShaftAngle.OnSimpleReadValue = (ISystemContext context, NodeState node, ref Variant value) => { - value = new Variant(ShaftAngleDegrees); + value = new Variant(shaftTwin.ShaftAngleDegrees); return ServiceResult.Good; }; @@ -287,32 +427,93 @@ private void AttachOpenUsdRepresentation(PumpState pump) // 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", - new Guid("6e63cf2c-f2de-4f78-a8f8-f0ccdbb7647a"), - m_shaftAngleVar.NodeId, "/Plant/Pumps/P101/Impeller", "xformOp:rotateZ", "double", - OpenUsdRenderTargetKindEnum.Rotation, 0.0025, + 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 20..100 degrees - // Celsius, so the binding declares the -273.15 shift. + // DisplayColor render target ramps blue -> red over the datasheet + // bearing-temperature range, so the binding declares the Kelvin shift. CreateBinding(rep, ns, "BearingTempColor", - new Guid("b1a1f6f0-5c2b-5a1e-9f3a-2b7c4d8e0011"), - bearingTemp, "/Plant/Pumps/P101/Body/Mat/Surface", "inputs:diffuseColor", "color3f", + GuidFor("BearingTempColor"), + bearingTemp, primPath + "/Body/Mat/Surface", "inputs:diffuseColor", "color3f", OpenUsdRenderTargetKindEnum.DisplayColor, 1.0, - offset: -273.15); - - // DifferentialPressure is published in Pascal; the EmissiveColor - // render target brightens over 0..6 bar, so scale Pa -> bar. - CreateBinding(rep, ns, "DiffPressureEmissive", - new Guid("c2b2a7e1-6d3c-5b2f-a04b-3c8d5e9f1122"), - diffPressure, "/Plant/Pumps/P101/StatusLight/Mat/Surface", "inputs:emissiveColor", "color3f", - OpenUsdRenderTargetKindEnum.EmissiveColor, 0.00001); - - CreateBinding(rep, ns, "AlarmActiveVisibility", - new Guid("d3c3b8f2-7e4d-5c30-b15c-4d9e6a0b2233"), - m_alarmActiveVar.NodeId, "/Plant/Pumps/P101/StatusLight", "visibility", "token", - OpenUsdRenderTargetKindEnum.Visibility, 1.0, - bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, - alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); + 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 @@ -324,26 +525,159 @@ private void AttachOpenUsdRepresentation(PumpState pump) // granted only to an authenticated (non-anonymous) session — a Server // "withholds by default" the RolePermissions a connector must hold before // issuing any command. - m_speedSetpointVar = CreatePumpVariable( + twin.SpeedSetpoint = CreatePumpVariable( pump, "SpeedSetpoint", Opc.Ua.DataTypeIds.Double, new Variant(0.0), writable: true); - m_speedSetpointVar.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel; + twin.SpeedSetpoint.OnReadUserAccessLevel = OnReadCommandTargetUserAccessLevel; CreateBinding(rep, ns, "SpeedSetpointCommand", - new Guid("e4d4c9a3-8f5e-5d41-c26d-5e0f7b1c3344"), - NodeId.Null, "/Plant/Pumps/P101/Impeller", "inputs:speedSetpoint", "double", + GuidFor("SpeedSetpointCommand"), + NodeId.Null, primPath + "/Impeller", "inputs:speedSetpoint", "double", kind: null, 1.0, bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdCommandBindingType, signalRole: OpenUsdSignalRoleEnum.Controllable, - commandTargetNodeId: m_speedSetpointVar.NodeId, + 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); + 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"; @@ -408,30 +742,31 @@ private BaseDataVariableState CreatePumpVariable( return v; } - private void OrganiseRepresentation(PumpState pump) + /// + /// 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 (BaseInstanceState child in EnumerateChildren(pump)) + foreach (PumpTwin twin in m_twins.Values) { - if (child is OpenUsdRepresentationState rep) + OpenUsdRepresentationState? rep = twin.Representation; + if (rep == null) { - registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId); - rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); + continue; } + registry.AddReference(ReferenceTypeIds.Organizes, false, rep.NodeId); + rep.AddReference(ReferenceTypeIds.Organizes, true, registry.NodeId); } } - private System.Collections.Generic.List EnumerateChildren(NodeState parent) - { - var children = new System.Collections.Generic.List(); - parent.GetChildren(SystemContext, children); - return children; - } - // 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. diff --git a/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs b/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs new file mode 100644 index 0000000000..7032f92be7 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs @@ -0,0 +1,232 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Pumps +{ + /// + /// The published characteristics of the simulated SimPump Corp + /// PumpX-2000, exactly as documented in + /// DATASHEET.md. + /// Every value the server publishes - nameplate, engineering ranges, + /// alarm trip points and simulated process values - is derived from + /// these constants, so the datasheet and the running address space + /// cannot drift apart. + /// + internal static class PumpDatasheet + { + /// + /// Nameplate data shared by every unit of the product. + /// + public static class Nameplate + { + public const string Manufacturer = "SimPump Corp"; + public const string ManufacturerUri = "https://simpump.example"; + public const string Model = "PumpX-2000"; + public const string ProductCode = "PX2000-32-160"; + public const string DeviceClass = "Pump"; + public const string HardwareRevision = "1.4"; + public const string SoftwareRevision = "2.5.3"; + public const string ArticleNumber = "PX2000-32-160-CI"; + public const string OrderProductCode = "PX2000-32-160-CI-M30"; + public const string TypeOfProduct = "Centrifugal pump, end-suction"; + public const string Supplier = "SimPump Corp"; + public const string CountryOfOrigin = "DE"; + public const string ProductInstanceUriPrefix = + "urn:simdevice:SimPump:PumpX-2000:"; + public const ushort YearOfConstruction = 2025; + public const byte MonthOfConstruction = 4; + public const int DayOfConstruction = 17; + } + + /// + /// Hydraulic design point and characteristic curves (section 3 of + /// the datasheet). Reference liquid is water at 20 °C. + /// + public static class Hydraulics + { + /// + /// Density of the reference liquid in kg/m³. + /// + public const double FluidDensity = 998.0; + + /// + /// Gravitational acceleration in m/s². + /// + public const double GravitationalAcceleration = 9.81; + + /// + /// Flow at the best efficiency point in m³/h. + /// + public const double RatedFlow = 25.0; + + /// + /// Shut-off head in m, the constant term of the head curve. + /// + public const double ShutoffHead = 32.0; + + /// + /// Quadratic term of the head curve in m/(m³/h)². + /// + public const double HeadCurveCoefficient = 0.0104; + + /// + /// Efficiency at the best efficiency point in percent. + /// + public const double RatedEfficiency = 72.0; + + /// + /// Curvature of the efficiency parabola about the best + /// efficiency point. + /// + public const double EfficiencyCurveFactor = 0.6; + + /// + /// Head at the best efficiency point in m. + /// + public const double RatedHead = + ShutoffHead - (HeadCurveCoefficient * RatedFlow * RatedFlow); + + /// + /// Mass flow at the best efficiency point in kg/s. + /// + public const double RatedMassFlow = FluidDensity * RatedFlow / 3600.0; + + /// + /// Rated speed of the pump set in min-1. The simulated + /// shaft turns in proportion to how close the pump runs to its + /// rated mass flow. + /// + public const double RatedSpeed = 2900.0; + + /// + /// Shaft power at the best efficiency point in W. + /// + public const double RatedShaftPower = + FluidDensity * GravitationalAcceleration * (RatedFlow / 3600.0) * + RatedHead / (RatedEfficiency / 100.0); + } + + /// + /// Engineering ranges published as the EURange property of + /// each measurement (section 4 of the datasheet). + /// + public static class Ranges + { + public const double DifferentialPressureMin = 0.0; + public const double DifferentialPressureMax = 400_000.0; + public const double FluidTemperatureMin = 263.15; + public const double FluidTemperatureMax = 393.15; + public const double BearingTemperatureMin = 273.15; + public const double BearingTemperatureMax = 423.15; + public const double PumpPowerInputMin = 0.0; + public const double PumpPowerInputMax = 4_000.0; + public const double MassFlowMin = 0.0; + public const double MassFlowMax = 10.0; + public const double PumpEfficiencyMin = 0.0; + public const double PumpEfficiencyMax = 100.0; + public const double LevelMin = 0.0; + public const double LevelMax = 5.0; + } + + /// + /// Bearing-temperature trip points and supervision thresholds + /// (section 7 of the datasheet), in K and m. + /// + public static class TripPoints + { + public const double BearingTemperatureHighHigh = 373.15; + public const double BearingTemperatureHigh = 363.15; + public const double BearingTemperatureLow = 283.15; + public const double BearingTemperatureLowLow = 278.15; + public const double MotorOverheatSet = 363.15; + public const double MotorOverheatClear = 361.15; + public const double CavitationSetLevel = 2.10; + public const double CavitationClearLevel = 2.20; + } + + /// + /// Deterministic simulation profile (section 8 of the datasheet). + /// Angular rates are expressed in radians per 250 ms tick. + /// + public static class Simulation + { + /// + /// Relative amplitude of the flow modulation about the rated + /// flow. + /// + public const double FlowModulation = 0.30; + + public const double FlowRate = 0.03; + public const double FluidTemperatureNominal = 313.15; + public const double FluidTemperatureAmplitude = 5.0; + public const double FluidTemperatureRate = 0.01; + public const double LevelNominal = 2.5; + public const double LevelAmplitude = 0.5; + public const double LevelRate = 0.02; + + /// + /// Bearing temperature in K at zero load. + /// + public const double BearingTemperatureBase = 323.15; + + /// + /// Bearing temperature rise in K at the rated shaft power. + /// + public const double BearingTemperatureLoadRise = 10.0; + + /// + /// Length of the bearing-cooling fault cycle in ticks. + /// + public const long CoolingFaultPeriodTicks = 64; + + /// + /// Tick within the cycle at which the cooling water is + /// interrupted. + /// + public const long CoolingFaultOnsetTick = 56; + + /// + /// Maximum bearing temperature rise in K caused by the + /// cooling fault. + /// + public const double CoolingFaultRise = 50.0; + + /// + /// Number of ticks between two simulated pump starts. + /// + public const long StartIntervalTicks = 3_600; + + /// + /// Phase offset in ticks applied per simulated pump so the + /// units do not move in lockstep. + /// + public const long PhaseOffsetTicks = 17; + } + } +} diff --git a/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj b/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj index e83ee45f02..1d1e9cdfcb 100644 --- a/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj +++ b/samples/PumpDeviceIntegrationServer/PumpDeviceIntegrationServer.csproj @@ -31,11 +31,10 @@ + twin geometry: Plant.usda (RootLayer) + pump.usda (Reference). --> Plant.usda pump.usda - remote-pump.usda |Organizes| DeviceSet + DeviceSet -->|Organizes| P1 + DeviceSet -->|Organizes| P2 + P1 -->|HasComponent| Id + P1 -->|HasComponent| Op + P1 -->|HasComponent| Ev + P1 -->|HasComponent| Maint + P1 -->|HasComponent| Diag + P1 -->|HasComponent| Usd + Op -->|HasComponent| Meas + Meas --> Vals + Ev -->|HasComponent| Alarm + Ev -->|HasComponent| SupF + Ev -->|HasComponent| SupP + P1 -.->|HasNotifier| Ev + Alarm -.->|SourceNode| Vals + Vals -.->|bound signals| Usd +``` + +Only the subtree of `Pump_1` is drawn; every further pump (`Pump_2`, …, +up to `--pumps N`) carries an identical one. `Diagnostics` is the one +group that is not part of the model — it is added declaratively per pump +by the non-typed `WithFunctionalGroup(QualifiedName, ...)` overload from +`Program.cs`. `OpenUsdRepresentation` carries the twin bindings described +in [The OpenUSD twin](#the-openusd-twin). + +## Startup and hosting flow + +```mermaid +sequenceDiagram + participant Host as HostApplicationBuilder + participant DI as AddOpcUa().AddServer() + participant Factory as PumpNodeManagerFactory + participant NM as PumpNodeManager + participant Runner as IDiPostSetupRunner + + Host->>DI: Configure(--pumps N) + Host->>DI: AddNodeManager() + Host->>DI: ConfigureDevicesFor(...) + DI->>Factory: CreateAsync(server, configuration) + Factory->>NM: new PumpNodeManager(.., postSetupRunner, options) + NM->>NM: LoadPredefinedNodesAsync
AddOpcUaDi + Machinery + Pumps + NM->>NM: OnAddressSpaceReadyAsync + loop for each of N pumps + NM->>NM: ConfigureInstancesAsync → Pump_n (PumpType) + NM->>NM: MaterialiseNameplate + optional children + end + NM->>NM: CreateFluentBuilder().Configure(Configure).Seal() + Note over NM: Configure wires identification, maintenance,
measurements, alarms and history per pump + Note over NM: Seal starts the 250 ms simulation loop + NM->>Runner: post-setup pipeline + Runner->>NM: TopologyElement(pumpNodeId).WithFunctionalGroup("Diagnostics") +``` + +## Simulation and alarm dataflow + +```mermaid +flowchart LR + Tick(["250 ms tick
builder.Simulation(...)"]) + Adv["AdvanceSimulation()"] + Sim["PumpSimulationState.Publish
per pump, phase offset 17 ticks"] + Curves["Datasheet curves
H(Q) · η(Q) · P = ρgQH/η"] + Upd["IValueUpdater<T>.SetValue"] + Vars["Measurement variables
EURange + EngineeringUnits"] + MI["MonitoredItems
client subscriptions"] + Ovr["MotorOverheat
≥ 363.15 K, hysteresis 361.15 K"] + Cav["Cavitation
level < 2.10 m, hysteresis 2.20 m"] + Alarm["OverTempAlarm
ActivatesAlarm edge tracker"] + Notif["HasNotifier chain
Pump → Server object"] + Ev(["Condition events"]) + Shaft["ShaftAngle
∫ speed dt, speed ∝ ṁ / ṁ rated"] + Usd(["OpenUSD twin bindings"]) + + Tick --> Adv --> Sim --> Curves --> Upd --> Vars --> MI + Sim --> Ovr --> Alarm --> Notif --> Ev + Sim --> Cav --> Vars + Vars -.->|SourceNode| Alarm + Curves --> Shaft --> Usd + Vars -.->|bound signals| Usd +``` + ## Validating the address space The @@ -85,7 +223,24 @@ The server also publishes an OpenUSD representation of the pump line embedded assets, so a connector can render the twin with no external asset resolver. -`Assets/Plant.usda` models **P101** as a real machine: a horizontal +**Every configured pump is a twin in its own right.** The DeviceSet +carries a plant-level representation anchored on `/Plant` whose single +`Many` component binding is scoped to `PumpType`, so a connector composes +one `@pump.usda@` reference prim per pump — `/Plant/Pumps/Pump_1`, +`/Plant/Pumps/Pump_2`, … — and each pump's own bindings drive only its own +prim. Start the server with `--pumps 6` and six fully modelled machines +appear, each turning at its own speed, each with its own gauges, fluid +level and alarm halos. Each pump publishes its bay as a +`ThreeDCartesianCoordinates` value bound to `xformOp:translate`, which is +what lays them out in a row without the stage having to author anything +per pump. + +The composition arc is `Reference`, not `Instance`: an instanceable prim +turns its descendants into a shared prototype, and a shared prototype +cannot carry the per-pump impeller rotation, casing colour or needle +positions that make each machine read as its own. + +`Assets/Plant.usda` models the pump as a real machine: a horizontal long-coupled end-suction centrifugal pump built to **EN 733** (formerly DIN 24255), size **65-200**, following the published dimensions of the Grundfos NK 65-200 / KSB Etanorm 65-200 family and driven by an @@ -100,12 +255,15 @@ Grundfos NK 65-200 / KSB Etanorm 65-200 family and driven by an | Discharge flange | DN65 vertical, OD 0.185 m (EN 1092-2 PN16) | | Impeller | 0.198 m, six backward-curved vanes | | Motor frame | 0.254 m outer diameter × 0.615 m long | +| Bay pitch | 2.4 m | Livery is KSB signal blue (RAL 5005) for the wetted castings and -RAL 7035 light grey for the motor. `pump.usda` is the same machine at a -lower level of detail, referenced once per aggregated line pump; -`remote-pump.usda` wears an OEM green livery so the pump federated from -the *remote* server is obvious at a glance. +RAL 7035 light grey for the motor. `pump.usda` is that machine as a +self-contained component asset, referenced once per configured pump. It is +generated from the `/Plant/Pumps/P101` master by +`Assets/generate_pump_assets.py` — edit the master and re-run the +generator, never the generated layer. The master itself is authored +`active = false`, because the composed pumps are what render. Two departures from a real pump are deliberate, so the twin can be *seen*: the suction pipe is drawn as a stub leaving the casing eye open @@ -113,37 +271,103 @@ Two departures from a real pump are deliberate, so the twin can be guard is a cage rather than a solid barrel. Both let you watch the shaft turn. +### The hall + +The rendered hall holds **exactly the pumps the connected server simulates** — +one referenced `pump.usda` prim per configured pump, one bay every 2.4 m along ++Y, each with its suction vessel behind it. Nothing else is drawn: the +`ProductionLine` aggregation and the cross-server component are address-space +topology, not machines anyone is driving, so composing them put pumps in the +twin that no client could account for, and a pump federated from *another* +server is not this server's to show at all. + +`HeroCamera` is an operator's viewpoint: eye height 1.65 m in the aisle on the +discharge side, pitched 7° below horizontal, framing every configured pump at a +three-quarter angle with the vessels behind them. The framing holds from one +pump up to eight, so it does not have to be retuned for `--pumps N`. Pass +`--camera /Plant/HeroCamera` to start on it. + +An error 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. The ring says *that* a pump is in +alarm; the two fault halos — at the suction eye for cavitation, on the bearing +bracket for motor overheat — say *why*. All three are authored `invisible` and +only a live supervision binding reveals them, so a machine nothing is bound to +never shows an alarm it did not raise. + ### Live bindings +All paths are relative to the pump's own prim, so every pump drives its +own copy of every target. + | Source | USD target | Effect | | --- | --- | --- | -| `ShaftAngle` | `…/P101/Impeller.xformOp:rotateZ` | turns the shaft, impeller and coupling | -| `BearingTemperature` | `…/P101/Body/Mat/Surface.inputs:diffuseColor` | casing colour, blue (cool) → red (hot) | -| `DifferentialPressure` | `…/StatusLight/Mat/Surface.inputs:emissiveColor` | lamp glow tracks discharge pressure | -| supervision alarm | `…/P101/StatusLight.visibility` | shows the alarm halo | +| `BayPosition` | `xformOp:translate` | places the pump in its bay | +| `ShaftAngle` | `…/Impeller.xformOp:rotateZ` | turns the shaft, impeller and coupling | +| `ShaftAngle` | `…/Motor/FanBlades.xformOp:rotateZ` | motor cooling fan | +| `BearingTemperature` | `…/Body/Mat/Surface.inputs:diffuseColor` | casing colour, blue (cool) → red (hot) | +| `BearingTemperature` | `…/PowerEnd/TempGauge/Needle.xformOp:rotateZ` | bearing-temperature gauge | +| `DifferentialPressure` | `…/Discharge/Gauge/Needle.xformOp:rotateZ` | discharge pressure gauge | +| `FluidSurfacePosition` | `…/SuctionVessel/Surface.xformOp:translate` | liquid surface rides on the published `Level` | +| `FluidTemperature` | `…/Suction/Neck/Mat/Surface.inputs:diffuseColor` | suction line tint | +| `MassFlow`, `PumpEfficiency`, `NumberOfStarts` | `…/Motor/Nameplate.inputs:*` | readouts a viewer shows on selection | +| any supervision alarm | `…/AlarmRing.visibility` | red circle on the floor around the machine | +| `Cavitation` | `…/Suction/CavitationHalo.visibility` | cavitation, at the suction eye where the fault is | +| `MotorOverheat` | `…/OverheatHalo.visibility` | overheat, on the bearing bracket | +| `SpeedSetpoint` | `…/Impeller.inputs:speedSetpoint` | opt-in `UsdToUaCommand`, fail-closed | `MassFlow` is a *rate*, so binding it straight to a rotation op pins the shaft at a fraction of a degree and the pump looks dead. The simulation integrates the running speed into a `ShaftAngle` instead, and the binding scales it down to a legible ~45°/s — a real 2900 rpm shaft would alias into a stroboscopic blur at any practical sampling rate. Speed follows -flow, so the impeller visibly slows and picks up with the duty point. - -The beacon mast, housing and lamp are permanently mounted; only the alarm -halo is gated by `visibility`, so a cleared alarm still leaves a lamp -whose glow tracks discharge pressure. +flow, so the impeller visibly slows and picks up with the duty point, and +the phase offset the simulation gives each pump means no two shafts are +ever at the same angle. + +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, and binding those +would ring every pump in the hall at once. The per-pump supervision +states are the alarm indication instead: they are genuinely per instance, +and they are what drives the condition through `ActivatesAlarm` in the +first place. A real pump shaft is horizontal, but the binding contract fixes the driven operation as `xformOp:rotateZ`. `Impeller` therefore carries a static `xformOp:rotateY = 90` *ahead of* `xformOp:rotateZ` in `xformOpOrder`, which lays its local Z along the world shaft axis. The impeller and the coupling both hang off that one rotating prim, so they -turn together — as they do on the real machine. +turn together — as they do on the real machine. The gauge needles use the +same trick with `xformOp:rotateX = 90`, because their dials face the +plant Y axis. + +Which op a bound prim has to declare depends on the render target, and +getting it wrong fails silently. A connector accumulates `Translation`, +`Rotation` and `Scale` into a *single* `xformOp:transform` matrix, so +that it never has to rewrite `xformOpOrder` — the list is `uniform`, and +an opinion in the asset layer cannot be cleared from the stronger layer a +connector edits. Every other `xformOp:` property, such as the scalar +`xformOp:rotateZ` above, is authored under its own name. USD evaluates +only the ops named in `xformOpOrder`, so a prim bound to a `Translation` +target must declare `xformOp:transform` — the pump root and the suction +vessel's fluid surface both do. Naming `xformOp:translate` there instead +makes USD discard every value written to it: the pumps keep reporting +their bay positions over OPC UA, and every one of them renders on the +origin, so the hall looks like it holds a single machine. +`TransformBindingsTargetDeclaredXformOpsAsync` checks each transform +binding against the op the served asset actually declares. Because the render targets expect degrees Celsius and bar while OPC -40223 publishes Kelvin and Pascal, the two colour bindings declare the +40223 publishes Kelvin and Pascal, the colour bindings declare the conversion themselves (`offset: -273.15` and `scale: 1e-5`); §5.8 -applies `Scale` then `Offset`. +applies `Scale` then `Offset`. The gauge scales are derived from the +`PumpDatasheet` engineering ranges, so a datasheet change moves the +needles with it. `PumpEfficiency` is a readout rather than a colour: the +`DisplayColor` ramp models a temperature, and colouring efficiency with +it would read as a lie. ### Viewing it @@ -211,18 +435,21 @@ workflow on every push to `master` and on manual dispatch. |---------|-------| | `AddOpcUa().AddServer(...).AddNodeManager()` hosting | `Program.cs` | | Multi-model composition (DI library + locally source-generated Machinery + Pumps) | `PumpNodeManager.cs` `LoadPredefinedNodesAsync` | +| Optional nameplate materialisation via generator-emitted `AddXxx(context)` helpers across three namespaces (DI / Machinery / Pumps) | `PumpNodeManager.cs` `MaterialiseNameplate` | | Identification properties via `WithProperty(name, value)` | `PumpNodeManager.Configure.cs` `WithIdentification` | | Optional-child materialisation via generator-emitted `AddXxx(context)` helpers (Operational / Measurements / Events / SupervisionProcessFluid / SupervisionPumpOperation / Maintenance) | `PumpNodeManager.cs` `MaterialisePumpOptionalChildren` | -| Engineering units / EURange via `WithEngineeringUnits` / `WithEURange` | `WithMeasurements` | +| Engineering units / EURange via `WithEngineeringUnits` / `WithEURange` | `CreatePumpSimulation` | | Push-style monitored value updates via `Bind(out IValueUpdater)` | `CreatePumpSimulation` | | Discrete `NumberOfStarts` counter published on change | `CreatePumpSimulation` | | One 250 ms simulation tick for all phase-shifted pumps | `Configure` → `AdvanceSimulation` | -| Browsable/subscribable limit alarm with thresholds and acknowledge handler via `CreateLimitAlarm(...).WithLimits(...)` | `CreatePumpSimulation` | +| Datasheet-driven simulation (one independent variable, derived values) | `PumpDatasheet.cs` + `PumpSimulationState.Publish` | +| Browsable/subscribable limit alarm with thresholds and acknowledge handler via `CreateLimitAlarm(...).WithLimits(...).MonitorVariable(...)` | `CreatePumpSimulation` | | Boolean supervision (TwoStateDiscreteState) → alarm activation, condition raise/clear, and reported events via `.ActivatesAlarm(...)` | `CreatePumpSimulation` | | `EventNotifier`, `HasNotifier`, and `HasEventSource` instance wiring | `PumpNodeManager.cs` + fluent alarm builders | -| Cross-namespace path resolution (Pump #1 in Pumps NS → Operational in Machinery NS → Measurements in Pumps NS, all in one unqualified browse path) | `src/Opc.Ua.Server/Fluent/BrowsePathResolver.cs` | +| Cross-namespace path resolution (Pump_1 in Pumps NS → Operational in Machinery NS → Measurements in Pumps NS, all in one unqualified browse path) | `src/Opc.Ua.Server/Fluent/BrowsePathResolver.cs` | | Declarative `ConfigureDevicesFor` topology-element configuration adding an application-namespace Diagnostics functional group to every generated `PumpType` instance | `Program.cs` | | In-memory historian wiring so NodeSet-declared historical access is genuinely serviceable for all analog measurements and historized supervision booleans | `PumpNodeManager.Configure.cs` `UseHistorian()` / `Historize()` | +| One OpenUSD twin per configured pump: per-pump prim, signals and bindings, composed from one component asset by a `Many` component binding | `OpenUsdRepresentation.cs` + `OpenUsdComposition.cs` | ## Architecture @@ -234,9 +461,21 @@ PumpDeviceIntegrationServer/ │ # + LoadPredefinedNodesAsync (multi-model) │ # + CreateAddressSpaceAsync (builder setup) ├── PumpNodeManager.Configure.cs # partial — fluent wiring + simulation tick +├── OpenUsdRepresentation.cs # partial — one twin per pump: prim path, +│ # signal Variables and live bindings +├── OpenUsdComposition.cs # partial — plant aggregation (one prim per +│ # configured pump) + ProductionLine demo +├── PumpDatasheet.cs # DATASHEET.md as compile-time constants +├── DATASHEET.md # official-style PumpX-2000 product datasheet ├── PumpDeviceIntegrationServer.csproj # ProjectReference to Opc.Ua.Di model lib │ # AdditionalFiles for Machinery + Pumps │ # NodeSet2 (consumed by source generator) +├── Assets/ +│ ├── Plant.usda # stage master (P101 is the authoring +│ │ # master, deactivated so only the +│ │ # composed pumps render) +│ ├── pump.usda # generated component asset, one per pump +│ └── generate_pump_assets.py # regenerates it from the P101 master ├── Model/ │ ├── Opc.Ua.Machinery.NodeSet2.xml # AdditionalFiles — build-time only │ └── Opc.Ua.Pumps.NodeSet2.xml # AdditionalFiles — build-time only @@ -271,12 +510,23 @@ hierarchy and `ReportEvent`. ## Extending the sample +> When you change a published value, update +> [`DATASHEET.md`](./DATASHEET.md) and the constants in `PumpDatasheet.cs` +> together — `PumpDatasheetConformanceTests` fails the build otherwise. + - **Add a measurement**: open `PumpNodeManager.Configure.cs`, add a bound updater in `CreatePumpSimulation`, store it in - `PumpSimulationState`, and publish its value from `Publish`. + `PumpSimulationState`, and publish its value from `Publish`. Add its + engineering range to `PumpDatasheet.Ranges` and to section 4 of the + datasheet. - **Add an alarm**: create it from the typed `Events` builder in `CreatePumpSimulation` and wire the triggering boolean variable via - `.ActivatesAlarm(...)`. + `.ActivatesAlarm(...)`. Document its trip points in section 7 of the + datasheet. +- **Add a nameplate field**: materialise it with the generator-emitted + `AddXxx(context)` helper in `PumpNodeManager.MaterialiseNameplate`, + assign its value in `WithIdentification`, and add the row to section 2 + of the datasheet. - **Add pumps**: pass `--pumps N` (or set `pumps=N`) to materialise N identical simulated `PumpType` instances. `PumpNodeManager` demonstrates the hand-written fluent style by creating every pump, wiring @@ -301,6 +551,8 @@ generated model factories are statically rooted. ## See also +- [`DATASHEET.md`](./DATASHEET.md) — the official-style PumpX-2000 + product datasheet the simulation implements. - [`docs/DeviceIntegration.md`](../../docs/DeviceIntegration.md) — full developer guide for the DI library trio (device builder, hosting integration, lock service, software-update package store, diff --git a/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj b/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj index 265f506d90..3a63e35a2b 100644 --- a/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj +++ b/tests/Opc.Ua.Di.Tests/Opc.Ua.Di.Tests.csproj @@ -55,6 +55,7 @@ + diff --git a/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs new file mode 100644 index 0000000000..c0ac143ca3 --- /dev/null +++ b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs @@ -0,0 +1,309 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Pumps; +using Opc.Ua.Server; +using Opc.Ua.Server.TestFramework; +using Pumps; + +namespace Opc.Ua.Di.Tests +{ + /// + /// Pins the address space of the PumpDeviceIntegrationServer sample + /// to the published PumpX-2000 datasheet + /// (samples/PumpDeviceIntegrationServer/DATASHEET.md). Every value + /// asserted here is quoted from that document, so a change to the sample + /// that is not reflected in the datasheet - or the other way round - fails + /// the build. + /// + [TestFixture] + [Category("Pumps")] + [NonParallelizable] + public sealed class PumpDatasheetConformanceTests + { + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + m_fixture = new ServerFixture(t => new StandardServer(t)) + { + AutoAccept = true, + SecurityNone = true + }; + StandardServer server = await m_fixture.StartAsync().ConfigureAwait(false); + m_manager = new PumpNodeManager(server.CurrentInstance, m_fixture.Config); + var externalReferences = new Dictionary>(); + await m_manager.CreateAddressSpaceAsync(externalReferences).ConfigureAwait(false); + + m_pump = m_manager.FindPredefinedNode( + new NodeId("5001_Pump_1", m_manager.InstanceNamespaceIndex)); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + m_manager?.Dispose(); + if (m_fixture != null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + } + + /// + /// Section 2 of the datasheet - nameplate of unit SN-001. + /// + [Test] + public void NameplateMatchesTheDatasheet() + { + PumpIdentificationState identification = m_pump!.Identification!; + + Assert.Multiple(() => + { + Assert.That( + identification.Manufacturer!.Value.Text, + Is.EqualTo("SimPump Corp")); + Assert.That( + identification.ManufacturerUri!.Value, + Is.EqualTo("https://simpump.example")); + Assert.That(identification.Model!.Value.Text, Is.EqualTo("PumpX-2000")); + Assert.That( + identification.ProductCode!.Value, + Is.EqualTo("PX2000-32-160")); + Assert.That(identification.DeviceClass!.Value, Is.EqualTo("Pump")); + Assert.That(identification.HardwareRevision!.Value, Is.EqualTo("1.4")); + Assert.That(identification.SoftwareRevision!.Value, Is.EqualTo("2.5.3")); + Assert.That(identification.SerialNumber!.Value, Is.EqualTo("SN-001")); + Assert.That( + identification.ProductInstanceUri!.Value, + Is.EqualTo("urn:simdevice:SimPump:PumpX-2000:SN-001")); + Assert.That(identification.AssetId!.Value, Is.EqualTo("PMP-1001")); + Assert.That( + identification.ComponentName!.Value.Text, + Is.EqualTo("Feed Pump A")); + Assert.That( + identification.Location!.Value, + Is.EqualTo("Plant 1 / Utility Skid / Bay 3")); + Assert.That(identification.YearOfConstruction!.Value, Is.EqualTo(2025)); + Assert.That(identification.MonthOfConstruction!.Value, Is.EqualTo(4)); + Assert.That(identification.DayOfConstruction!.Value, Is.EqualTo(17)); + Assert.That( + identification.ArticleNumber!.Value, + Is.EqualTo("PX2000-32-160-CI")); + Assert.That( + identification.OrderProductCode!.Value, + Is.EqualTo("PX2000-32-160-CI-M30")); + Assert.That( + identification.TypeOfProduct!.Value, + Is.EqualTo("Centrifugal pump, end-suction")); + Assert.That(identification.Supplier!.Value, Is.EqualTo("SimPump Corp")); + Assert.That(identification.CountryOfOrigin!.Value, Is.EqualTo("DE")); + Assert.That( + identification.FabricationNumber!.Value, + Is.EqualTo("F-2025-0001")); + }); + } + + /// + /// Section 4 of the datasheet - engineering units and ranges of every + /// measurement. + /// + [Test] + public void MeasurementRangesMatchTheDatasheet() + { + MeasurementsState measurements = m_pump!.Operational!.Measurements!; + + Assert.Multiple(() => + { + AssertRange(measurements.DifferentialPressure!, "Pa", 0.0, 400_000.0); + AssertRange(measurements.FluidTemperature!, "K", 263.15, 393.15); + AssertRange(measurements.BearingTemperature!, "K", 273.15, 423.15); + AssertRange(measurements.PumpPowerInput!, "W", 0.0, 4_000.0); + AssertRange(measurements.MassFlow!, "kg/s", 0.0, 10.0); + AssertRange(measurements.PumpEfficiency!, "%", 0.0, 100.0); + AssertRange(measurements.Level!, "m", 0.0, 5.0); + }); + } + + /// + /// Section 7.1 of the datasheet - the bearing-temperature trip points + /// and the measurement the alarm reports on. + /// + [Test] + public void AlarmTripPointsMatchTheDatasheet() + { + ushort pumpsNamespaceIndex = (ushort)m_manager!.Server.NamespaceUris + .GetIndex(global::Opc.Ua.Pumps.Namespaces.Pumps); + NodeState? alarmNode = m_pump!.Events!.FindChild( + m_manager.SystemContext, + new QualifiedName("OverTempAlarm", pumpsNamespaceIndex)); + + Assert.That(alarmNode, Is.InstanceOf()); + var alarm = (NonExclusiveLimitAlarmState)alarmNode!; + + Assert.Multiple(() => + { + Assert.That(alarm.HighHighLimit!.Value, Is.EqualTo(373.15)); + Assert.That(alarm.HighLimit!.Value, Is.EqualTo(363.15)); + Assert.That(alarm.LowLimit!.Value, Is.EqualTo(283.15)); + Assert.That(alarm.LowLowLimit!.Value, Is.EqualTo(278.15)); + Assert.That( + alarm.SourceNode!.Value, + Is.EqualTo(m_pump.Operational!.Measurements!.BearingTemperature!.NodeId), + "The limit alarm must report the bearing temperature chain."); + Assert.That(alarm.SourceName!.Value, Is.EqualTo("BearingTemperature")); + }); + } + + /// + /// Section 8 of the datasheet - the simulated values must stay inside + /// the published operating envelope. + /// + [Test] + public void SimulatedValuesStayWithinTheDatasheetEnvelope() + { + MeasurementsState measurements = m_pump!.Operational!.Measurements!; + + Assert.Multiple(() => + { + AssertWithin(measurements.DifferentialPressure!, 205_000.0, 283_000.0); + AssertWithin(measurements.MassFlow!, 4.85, 9.02); + AssertWithin(measurements.PumpEfficiency!, 68.1, 72.0); + AssertWithin(measurements.PumpPowerInput!, 2_013.0, 2_728.0); + AssertWithin(measurements.BearingTemperature!, 331.5, 378.3); + AssertWithin(measurements.FluidTemperature!, 308.15, 318.15); + AssertWithin(measurements.Level!, 2.0, 3.0); + }); + } + + /// + /// Section 3.2 of the datasheet - the published values are derived from + /// one flow through the characteristic curves, so shaft power, mass + /// flow, differential pressure and efficiency must satisfy + /// P = Δp · Q / η at every instant. + /// + [Test] + public async Task SimulatedValuesAreHydraulicallyConsistentAsync() + { + MeasurementsState measurements = m_pump!.Operational!.Measurements!; + + // The 250 ms simulation loop is running, so a tick can land + // between two reads of the sample. Retry until one sample was + // taken between two ticks; a genuinely inconsistent model fails + // every attempt. + double deviation = double.MaxValue; + bool sampled = false; + for (int attempt = 0; attempt < MaxSampleAttempts; attempt++) + { + double massFlow = measurements.MassFlow!.Value; + double differentialPressure = measurements.DifferentialPressure!.Value; + double efficiency = measurements.PumpEfficiency!.Value; + double shaftPower = measurements.PumpPowerInput!.Value; + + // Guard against the initial defaults: a pump that has not + // published a complete sample yet would turn the ratio below + // into NaN or Infinity and mask the actual assertion. + if (massFlow <= 0.0 || efficiency <= 0.0 || shaftPower <= 0.0) + { + await Task.Delay(SampleRetryDelayMilliseconds) + .ConfigureAwait(false); + continue; + } + + sampled = true; + + // Volumetric flow in m³/s from the published mass flow. + double volumeFlow = massFlow / FluidDensity; + double expectedPower = differentialPressure * volumeFlow / + (efficiency / 100.0); + deviation = Math.Abs(expectedPower - shaftPower) / shaftPower; + if (deviation <= ConsistencyTolerance) + { + break; + } + } + + Assert.That( + sampled, + Is.True, + "The simulation never published a complete sample."); + Assert.That( + deviation, + Is.LessThanOrEqualTo(ConsistencyTolerance), + "Shaft power must equal differential pressure times volume flow " + + "divided by efficiency."); + } + + private static void AssertRange( + BaseAnalogState measurement, + string expectedUnit, + double expectedLow, + double expectedHigh) + { + Assert.That( + measurement.EngineeringUnits!.Value.DisplayName.Text, + Is.EqualTo(expectedUnit), + measurement.BrowseName.Name); + Assert.That( + measurement.EURange!.Value.Low, + Is.EqualTo(expectedLow), + measurement.BrowseName.Name); + Assert.That( + measurement.EURange.Value.High, + Is.EqualTo(expectedHigh), + measurement.BrowseName.Name); + } + + private static void AssertWithin( + BaseAnalogState measurement, + double low, + double high) + { + Assert.That( + measurement.Value, + Is.InRange(low, high), + measurement.BrowseName.Name); + Assert.That( + measurement.Value, + Is.InRange(measurement.EURange!.Value.Low, measurement.EURange.Value.High), + measurement.BrowseName.Name + " outside its EURange"); + } + + private const double FluidDensity = 998.0; + private const double ConsistencyTolerance = 1e-9; + private const int MaxSampleAttempts = 10; + private const int SampleRetryDelayMilliseconds = 50; + + private ServerFixture? m_fixture; + private PumpNodeManager? m_manager; + private PumpState? m_pump; + } +} diff --git a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs index de2cbdcc11..2f820500a2 100644 --- a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs @@ -31,6 +31,8 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Pumps; @@ -67,14 +69,13 @@ public sealed class PumpInstanceNodeIdRegressionTests private static readonly string[] s_expectedPumpSurface = [ "AlarmActive", + "BayPosition", "Bearing", "Bearing/OpenUsdRepresentation", "Bearing/OpenUsdRepresentation/PrimPath", "Bearing/OpenUsdRepresentation/Stage", "Events", "Events/OverTempAlarm", - // The fluent alarm builder attaches four unnamed condition - // children; they are part of the captured baseline. "Events/OverTempAlarm/", "Events/OverTempAlarm/", "Events/OverTempAlarm/", @@ -83,8 +84,6 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/OverTempAlarm/AckedState/Id", "Events/OverTempAlarm/Acknowledge", "Events/OverTempAlarm/Acknowledge/InputArguments", - // Argument properties on argument-less standard methods, as pinned - // by the source generator change earlier in this stack. "Events/OverTempAlarm/Acknowledge/OutputArguments", "Events/OverTempAlarm/ActiveState", "Events/OverTempAlarm/ActiveState/Id", @@ -129,14 +128,29 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/SupervisionPumpOperation/MotorOverheat", "Events/SupervisionPumpOperation/MotorOverheat/FalseState", "Events/SupervisionPumpOperation/MotorOverheat/TrueState", + "FluidSurfacePosition", "Identification", + "Identification/ArticleNumber", + "Identification/AssetId", + "Identification/ComponentName", + "Identification/CountryOfOrigin", + "Identification/DayOfConstruction", "Identification/DeviceClass", + "Identification/FabricationNumber", "Identification/HardwareRevision", + "Identification/Location", "Identification/Manufacturer", + "Identification/ManufacturerUri", "Identification/Model", + "Identification/MonthOfConstruction", + "Identification/OrderProductCode", + "Identification/ProductCode", "Identification/ProductInstanceUri", "Identification/SerialNumber", "Identification/SoftwareRevision", + "Identification/Supplier", + "Identification/TypeOfProduct", + "Identification/YearOfConstruction", "Impeller", "Impeller/OpenUsdRepresentation", "Impeller/OpenUsdRepresentation/PrimPath", @@ -145,19 +159,31 @@ public sealed class PumpInstanceNodeIdRegressionTests "Maintenance/GeneralMaintenance", "Maintenance/GeneralMaintenance/MaintenancePlan", "OpenUsdRepresentation", - "OpenUsdRepresentation/AlarmActiveVisibility", - "OpenUsdRepresentation/AlarmActiveVisibility/AlarmAspect", - "OpenUsdRepresentation/AlarmActiveVisibility/BadQualityAction", - "OpenUsdRepresentation/AlarmActiveVisibility/BindingDefinitionId", - "OpenUsdRepresentation/AlarmActiveVisibility/Enabled", - "OpenUsdRepresentation/AlarmActiveVisibility/RenderTargetKind", - "OpenUsdRepresentation/AlarmActiveVisibility/Scale", - "OpenUsdRepresentation/AlarmActiveVisibility/SignalRole", - "OpenUsdRepresentation/AlarmActiveVisibility/SourceNodeId", - "OpenUsdRepresentation/AlarmActiveVisibility/TargetPrimPath", - "OpenUsdRepresentation/AlarmActiveVisibility/TargetPropertyName", - "OpenUsdRepresentation/AlarmActiveVisibility/TargetStage", - "OpenUsdRepresentation/AlarmActiveVisibility/TargetUsdTypeName", + "OpenUsdRepresentation/AlarmRingVisibility", + "OpenUsdRepresentation/AlarmRingVisibility/AlarmAspect", + "OpenUsdRepresentation/AlarmRingVisibility/BadQualityAction", + "OpenUsdRepresentation/AlarmRingVisibility/BindingDefinitionId", + "OpenUsdRepresentation/AlarmRingVisibility/Enabled", + "OpenUsdRepresentation/AlarmRingVisibility/RenderTargetKind", + "OpenUsdRepresentation/AlarmRingVisibility/Scale", + "OpenUsdRepresentation/AlarmRingVisibility/SignalRole", + "OpenUsdRepresentation/AlarmRingVisibility/SourceNodeId", + "OpenUsdRepresentation/AlarmRingVisibility/TargetPrimPath", + "OpenUsdRepresentation/AlarmRingVisibility/TargetPropertyName", + "OpenUsdRepresentation/AlarmRingVisibility/TargetStage", + "OpenUsdRepresentation/AlarmRingVisibility/TargetUsdTypeName", + "OpenUsdRepresentation/BayLayout", + "OpenUsdRepresentation/BayLayout/BadQualityAction", + "OpenUsdRepresentation/BayLayout/BindingDefinitionId", + "OpenUsdRepresentation/BayLayout/Enabled", + "OpenUsdRepresentation/BayLayout/RenderTargetKind", + "OpenUsdRepresentation/BayLayout/Scale", + "OpenUsdRepresentation/BayLayout/SignalRole", + "OpenUsdRepresentation/BayLayout/SourceNodeId", + "OpenUsdRepresentation/BayLayout/TargetPrimPath", + "OpenUsdRepresentation/BayLayout/TargetPropertyName", + "OpenUsdRepresentation/BayLayout/TargetStage", + "OpenUsdRepresentation/BayLayout/TargetUsdTypeName", "OpenUsdRepresentation/BearingComponent", "OpenUsdRepresentation/BearingComponent/BindingDefinitionId", "OpenUsdRepresentation/BearingComponent/Cardinality", @@ -178,18 +204,69 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/BearingTempColor/TargetPropertyName", "OpenUsdRepresentation/BearingTempColor/TargetStage", "OpenUsdRepresentation/BearingTempColor/TargetUsdTypeName", - "OpenUsdRepresentation/DiffPressureEmissive", - "OpenUsdRepresentation/DiffPressureEmissive/BadQualityAction", - "OpenUsdRepresentation/DiffPressureEmissive/BindingDefinitionId", - "OpenUsdRepresentation/DiffPressureEmissive/Enabled", - "OpenUsdRepresentation/DiffPressureEmissive/RenderTargetKind", - "OpenUsdRepresentation/DiffPressureEmissive/Scale", - "OpenUsdRepresentation/DiffPressureEmissive/SignalRole", - "OpenUsdRepresentation/DiffPressureEmissive/SourceNodeId", - "OpenUsdRepresentation/DiffPressureEmissive/TargetPrimPath", - "OpenUsdRepresentation/DiffPressureEmissive/TargetPropertyName", - "OpenUsdRepresentation/DiffPressureEmissive/TargetStage", - "OpenUsdRepresentation/DiffPressureEmissive/TargetUsdTypeName", + "OpenUsdRepresentation/BearingTempNeedle", + "OpenUsdRepresentation/BearingTempNeedle/BadQualityAction", + "OpenUsdRepresentation/BearingTempNeedle/BindingDefinitionId", + "OpenUsdRepresentation/BearingTempNeedle/Enabled", + "OpenUsdRepresentation/BearingTempNeedle/Offset", + "OpenUsdRepresentation/BearingTempNeedle/RenderTargetKind", + "OpenUsdRepresentation/BearingTempNeedle/Scale", + "OpenUsdRepresentation/BearingTempNeedle/SignalRole", + "OpenUsdRepresentation/BearingTempNeedle/SourceNodeId", + "OpenUsdRepresentation/BearingTempNeedle/TargetPrimPath", + "OpenUsdRepresentation/BearingTempNeedle/TargetPropertyName", + "OpenUsdRepresentation/BearingTempNeedle/TargetStage", + "OpenUsdRepresentation/BearingTempNeedle/TargetUsdTypeName", + "OpenUsdRepresentation/CavitationHalo", + "OpenUsdRepresentation/CavitationHalo/AlarmAspect", + "OpenUsdRepresentation/CavitationHalo/BadQualityAction", + "OpenUsdRepresentation/CavitationHalo/BindingDefinitionId", + "OpenUsdRepresentation/CavitationHalo/Enabled", + "OpenUsdRepresentation/CavitationHalo/RenderTargetKind", + "OpenUsdRepresentation/CavitationHalo/Scale", + "OpenUsdRepresentation/CavitationHalo/SignalRole", + "OpenUsdRepresentation/CavitationHalo/SourceNodeId", + "OpenUsdRepresentation/CavitationHalo/TargetPrimPath", + "OpenUsdRepresentation/CavitationHalo/TargetPropertyName", + "OpenUsdRepresentation/CavitationHalo/TargetStage", + "OpenUsdRepresentation/CavitationHalo/TargetUsdTypeName", + "OpenUsdRepresentation/DischargePressureNeedle", + "OpenUsdRepresentation/DischargePressureNeedle/BadQualityAction", + "OpenUsdRepresentation/DischargePressureNeedle/BindingDefinitionId", + "OpenUsdRepresentation/DischargePressureNeedle/Enabled", + "OpenUsdRepresentation/DischargePressureNeedle/RenderTargetKind", + "OpenUsdRepresentation/DischargePressureNeedle/Scale", + "OpenUsdRepresentation/DischargePressureNeedle/SignalRole", + "OpenUsdRepresentation/DischargePressureNeedle/SourceNodeId", + "OpenUsdRepresentation/DischargePressureNeedle/TargetPrimPath", + "OpenUsdRepresentation/DischargePressureNeedle/TargetPropertyName", + "OpenUsdRepresentation/DischargePressureNeedle/TargetStage", + "OpenUsdRepresentation/DischargePressureNeedle/TargetUsdTypeName", + "OpenUsdRepresentation/EfficiencyReadout", + "OpenUsdRepresentation/EfficiencyReadout/BadQualityAction", + "OpenUsdRepresentation/EfficiencyReadout/BindingDefinitionId", + "OpenUsdRepresentation/EfficiencyReadout/Enabled", + "OpenUsdRepresentation/EfficiencyReadout/RenderTargetKind", + "OpenUsdRepresentation/EfficiencyReadout/Scale", + "OpenUsdRepresentation/EfficiencyReadout/SignalRole", + "OpenUsdRepresentation/EfficiencyReadout/SourceNodeId", + "OpenUsdRepresentation/EfficiencyReadout/TargetPrimPath", + "OpenUsdRepresentation/EfficiencyReadout/TargetPropertyName", + "OpenUsdRepresentation/EfficiencyReadout/TargetStage", + "OpenUsdRepresentation/EfficiencyReadout/TargetUsdTypeName", + "OpenUsdRepresentation/FluidTempColor", + "OpenUsdRepresentation/FluidTempColor/BadQualityAction", + "OpenUsdRepresentation/FluidTempColor/BindingDefinitionId", + "OpenUsdRepresentation/FluidTempColor/Enabled", + "OpenUsdRepresentation/FluidTempColor/Offset", + "OpenUsdRepresentation/FluidTempColor/RenderTargetKind", + "OpenUsdRepresentation/FluidTempColor/Scale", + "OpenUsdRepresentation/FluidTempColor/SignalRole", + "OpenUsdRepresentation/FluidTempColor/SourceNodeId", + "OpenUsdRepresentation/FluidTempColor/TargetPrimPath", + "OpenUsdRepresentation/FluidTempColor/TargetPropertyName", + "OpenUsdRepresentation/FluidTempColor/TargetStage", + "OpenUsdRepresentation/FluidTempColor/TargetUsdTypeName", "OpenUsdRepresentation/ImpellerComponent", "OpenUsdRepresentation/ImpellerComponent/BindingDefinitionId", "OpenUsdRepresentation/ImpellerComponent/Cardinality", @@ -197,6 +274,56 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/ImpellerComponent/CompositionArc", "OpenUsdRepresentation/ImpellerComponent/Enabled", "OpenUsdRepresentation/ImpellerComponent/TargetPrimPath", + "OpenUsdRepresentation/MassFlowReadout", + "OpenUsdRepresentation/MassFlowReadout/BadQualityAction", + "OpenUsdRepresentation/MassFlowReadout/BindingDefinitionId", + "OpenUsdRepresentation/MassFlowReadout/Enabled", + "OpenUsdRepresentation/MassFlowReadout/RenderTargetKind", + "OpenUsdRepresentation/MassFlowReadout/Scale", + "OpenUsdRepresentation/MassFlowReadout/SignalRole", + "OpenUsdRepresentation/MassFlowReadout/SourceNodeId", + "OpenUsdRepresentation/MassFlowReadout/SourceSemanticId", + "OpenUsdRepresentation/MassFlowReadout/TargetPrimPath", + "OpenUsdRepresentation/MassFlowReadout/TargetPropertyName", + "OpenUsdRepresentation/MassFlowReadout/TargetStage", + "OpenUsdRepresentation/MassFlowReadout/TargetUsdTypeName", + "OpenUsdRepresentation/MotorFanSpin", + "OpenUsdRepresentation/MotorFanSpin/BadQualityAction", + "OpenUsdRepresentation/MotorFanSpin/BindingDefinitionId", + "OpenUsdRepresentation/MotorFanSpin/Enabled", + "OpenUsdRepresentation/MotorFanSpin/RenderTargetKind", + "OpenUsdRepresentation/MotorFanSpin/Scale", + "OpenUsdRepresentation/MotorFanSpin/SignalRole", + "OpenUsdRepresentation/MotorFanSpin/SourceNodeId", + "OpenUsdRepresentation/MotorFanSpin/TargetPrimPath", + "OpenUsdRepresentation/MotorFanSpin/TargetPropertyName", + "OpenUsdRepresentation/MotorFanSpin/TargetStage", + "OpenUsdRepresentation/MotorFanSpin/TargetUsdTypeName", + "OpenUsdRepresentation/NumberOfStartsReadout", + "OpenUsdRepresentation/NumberOfStartsReadout/BadQualityAction", + "OpenUsdRepresentation/NumberOfStartsReadout/BindingDefinitionId", + "OpenUsdRepresentation/NumberOfStartsReadout/Enabled", + "OpenUsdRepresentation/NumberOfStartsReadout/RenderTargetKind", + "OpenUsdRepresentation/NumberOfStartsReadout/Scale", + "OpenUsdRepresentation/NumberOfStartsReadout/SignalRole", + "OpenUsdRepresentation/NumberOfStartsReadout/SourceNodeId", + "OpenUsdRepresentation/NumberOfStartsReadout/TargetPrimPath", + "OpenUsdRepresentation/NumberOfStartsReadout/TargetPropertyName", + "OpenUsdRepresentation/NumberOfStartsReadout/TargetStage", + "OpenUsdRepresentation/NumberOfStartsReadout/TargetUsdTypeName", + "OpenUsdRepresentation/OverheatHalo", + "OpenUsdRepresentation/OverheatHalo/AlarmAspect", + "OpenUsdRepresentation/OverheatHalo/BadQualityAction", + "OpenUsdRepresentation/OverheatHalo/BindingDefinitionId", + "OpenUsdRepresentation/OverheatHalo/Enabled", + "OpenUsdRepresentation/OverheatHalo/RenderTargetKind", + "OpenUsdRepresentation/OverheatHalo/Scale", + "OpenUsdRepresentation/OverheatHalo/SignalRole", + "OpenUsdRepresentation/OverheatHalo/SourceNodeId", + "OpenUsdRepresentation/OverheatHalo/TargetPrimPath", + "OpenUsdRepresentation/OverheatHalo/TargetPropertyName", + "OpenUsdRepresentation/OverheatHalo/TargetStage", + "OpenUsdRepresentation/OverheatHalo/TargetUsdTypeName", "OpenUsdRepresentation/PrimPath", "OpenUsdRepresentation/ShaftSpin", "OpenUsdRepresentation/ShaftSpin/BadQualityAction", @@ -224,6 +351,18 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/SpeedSetpointCommand/TargetStage", "OpenUsdRepresentation/SpeedSetpointCommand/TargetUsdTypeName", "OpenUsdRepresentation/Stage", + "OpenUsdRepresentation/SuctionLevelRise", + "OpenUsdRepresentation/SuctionLevelRise/BadQualityAction", + "OpenUsdRepresentation/SuctionLevelRise/BindingDefinitionId", + "OpenUsdRepresentation/SuctionLevelRise/Enabled", + "OpenUsdRepresentation/SuctionLevelRise/RenderTargetKind", + "OpenUsdRepresentation/SuctionLevelRise/Scale", + "OpenUsdRepresentation/SuctionLevelRise/SignalRole", + "OpenUsdRepresentation/SuctionLevelRise/SourceNodeId", + "OpenUsdRepresentation/SuctionLevelRise/TargetPrimPath", + "OpenUsdRepresentation/SuctionLevelRise/TargetPropertyName", + "OpenUsdRepresentation/SuctionLevelRise/TargetStage", + "OpenUsdRepresentation/SuctionLevelRise/TargetUsdTypeName", "Operational", "Operational/Measurements", "Operational/Measurements/BearingTemperature", @@ -326,6 +465,46 @@ public void EveryPumpInstanceExposesTheSameSimulationSurface() Contains.Item("Events/SupervisionPumpOperation/MotorOverheat/TrueState")); } + /// + /// Every configured pump must contribute its shaft angle to the + /// simulation publish set exactly once. + /// + /// + /// Signal registration runs once per pump as it is materialised, and the + /// twin is already in the dictionary by then. Registering by walking every + /// twin therefore re-registered each earlier pump, so the first pump ended + /// up published N times per tick for N pumps - redundant writes and + /// ClearChangeMasks calls growing as N(N+1)/2. + /// + [Test] + public void EveryPumpPublishesItsShaftAngleExactlyOnce() + { + FieldInfo? field = typeof(PumpNodeManager).GetField( + "m_liveSignals", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(field, Is.Not.Null, + "PumpNodeManager.m_liveSignals is gone - update this regression test."); + + var signals = (Array)field!.GetValue(m_manager)!; + List published = signals + .Cast() + .Select(entry => ((BaseVariableState)((ITuple)entry)[0]!).NodeId) + .ToList(); + + Assert.Multiple(() => + { + Assert.That(published, Is.Unique, + "A Variable is published more than once per simulation tick."); + foreach (PumpState pump in new[] { m_configuredPump!, m_secondPump! }) + { + NodeId shaftAngle = new( + pump.NodeId.IdentifierAsString + "_ShaftAngle", + pump.NodeId.NamespaceIndex); + Assert.That(published.Count(id => id == shaftAngle), Is.EqualTo(1), + pump.BrowseName.Name + " must publish its shaft angle exactly once."); + } + }); + } + /// /// Every node the generated helpers materialise must carry a NodeId /// minted by - that is diff --git a/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs b/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs index a4daa0a582..dc83326074 100644 --- a/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -57,6 +58,12 @@ public sealed class PumpOpenUsdE2eTests { private ITelemetryContext m_telemetry = null!; private IHost? m_host; + /// + /// Pumps the fixture's server materialises, which is the + /// PumpDeviceIntegrationOptions default. + /// + private const int ExpectedPumpCount = 2; + private ISession? m_session; private ISession? m_privilegedSession; private ApplicationConfiguration m_clientConfig = null!; @@ -343,12 +350,12 @@ public async Task RepresentationAndBindingsAreDiscoverableAsync() var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); - Assert.That(rep, Is.Not.Null, "OpenUsdRepresentation not discovered on Pump #1."); - Assert.That(rep!.PrimPath, Is.EqualTo("/Plant/Pumps/P101")); + Assert.That(rep, Is.Not.Null, "OpenUsdRepresentation not discovered on Pump_1."); + Assert.That(rep!.PrimPath, Is.EqualTo("/Plant/Pumps/Pump_1")); Assert.That(rep.StageNodeId.IsNull, Is.False); Assert.That(rep.RootLayerIdentifier, Is.EqualTo("asset-repo/Plant.usd")); - // 0.1 telemetry (3) + 0.2 alarm (1) + 0.2 command (1) = 5 bindings. - Assert.That(rep.Bindings, Has.Count.EqualTo(5)); + // Layout (1) + telemetry (10) + supervision alarms (3) + command (1). + Assert.That(rep.Bindings, Has.Count.EqualTo(15)); } [Test] @@ -428,9 +435,9 @@ public async Task AlarmBindingDrivesUsdVisibilityAsync() await connector.StopAsync().ConfigureAwait(false); // The UaAlarmToUsd binding subscribes the alarm-active aspect and authors - // the status-light visibility token (initially "invisible" until an alarm). - Assert.That(sink.WasWritten("/Plant/Pumps/P101/StatusLight", "visibility"), Is.True, - "Alarm binding did not author StatusLight visibility."); + // the alarm-ring visibility token (initially "invisible" until an alarm). + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/AlarmRing", "visibility"), Is.True, + "Alarm binding did not author AlarmRing visibility."); } [Test] @@ -447,13 +454,23 @@ public async Task CommandBindingWritesServerVariableWhenEnabledAsync() { var connector = new OpenUsdConnector( m_privilegedSession!, new MockUsdSink(), enableCommands: true); - OpenUsdConnector.RepresentationInfo? rep = await PumpRepAsync(connector).ConfigureAwait(false); + // Every pump declares a command binding, and the connector issues the + // command to the first one it discovers, so the assertion has to read + // that same target rather than assuming a particular pump. + System.Collections.Generic.List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None) + .ConfigureAwait(false); NodeId target = NodeId.Null; - foreach (OpenUsdConnector.BindingInfo b in rep!.Bindings) + foreach (OpenUsdConnector.RepresentationInfo r in reps) { - if (b.Intent == OpenUsdIntentProfile.UsdToUaCommand) + foreach (OpenUsdConnector.BindingInfo b in r.Bindings) { - target = b.CommandTargetNodeId; + if (target.IsNull && + b.Intent == OpenUsdIntentProfile.UsdToUaCommand && + !b.CommandTargetNodeId.IsNull) + { + target = b.CommandTargetNodeId; + } } } Assert.That(target.IsNull, Is.False, "Command target NodeId missing."); @@ -547,12 +564,12 @@ public async Task LiveValuesFlowThroughConnectorToUsdSinkAsync() Assert.Multiple(() => { - Assert.That(sink.WasWritten("/Plant/Pumps/P101/Impeller", "xformOp:rotateZ"), Is.True, + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/Impeller", "xformOp:rotateZ"), Is.True, "Rotation binding produced no value."); - Assert.That(sink.WasWritten("/Plant/Pumps/P101/Body/Mat/Surface", "inputs:diffuseColor"), Is.True, + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/Body/Mat/Surface", "inputs:diffuseColor"), Is.True, "DisplayColor binding produced no value."); - Assert.That(sink.WasWritten("/Plant/Pumps/P101/StatusLight/Mat/Surface", "inputs:emissiveColor"), Is.True, - "EmissiveColor binding produced no value."); + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/Discharge/Gauge/Needle", "xformOp:rotateZ"), Is.True, + "Discharge pressure gauge produced no value."); Assert.That(sink.TotalWrites, Is.GreaterThan(0)); }); } @@ -569,9 +586,9 @@ public async Task PumpComponentsComposeChildPrimsAsync() { Assert.Multiple(() => { - Assert.That(sink.WasPrimComposed("/Plant/Pumps/P101/Impeller"), Is.True, + Assert.That(sink.WasPrimComposed("/Plant/Pumps/Pump_1/Impeller"), Is.True, "Impeller component prim not composed."); - Assert.That(sink.WasPrimComposed("/Plant/Pumps/P101/Bearing"), Is.True, + Assert.That(sink.WasPrimComposed("/Plant/Pumps/Pump_1/Bearing"), Is.True, "Bearing component prim not composed."); }); } @@ -582,10 +599,14 @@ public async Task PumpComponentsComposeChildPrimsAsync() } [Test] - public async Task ProductionLineAggregatesPumpsAsync() + public async Task OnlySimulatedPumpsAreComposedAsync() { - // 1..n composition (§5.12): a ProductionLine aggregates its pumps as - // instanceable reference prims under a Pumps scope. + // The rendered hall holds exactly the pumps the connected server + // simulates. The ProductionLine aggregation and the cross-server + // component are address-space topology, not machines anyone drives, + // so composing them put pumps in the twin that no client could + // account for - and a federated pump from another server is not this + // server's to show at all. var sink = new MockUsdSink(); var connector = new OpenUsdConnector(m_session!, sink); await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); @@ -593,11 +614,18 @@ public async Task ProductionLineAggregatesPumpsAsync() { Assert.Multiple(() => { - Assert.That(sink.WasPrimComposed("/Plant/Line1/Pumps/P_201"), Is.True, - "Aggregated pump P-201 prim not composed."); - Assert.That(sink.WasPrimComposed("/Plant/Line1/Pumps/P_202"), Is.True, - "Aggregated pump P-202 prim not composed."); - Assert.That(sink.IsPrimActive("/Plant/Line1/Pumps/P_201"), Is.True); + for (int i = 1; i <= ExpectedPumpCount; i++) + { + string prim = $"/Plant/Pumps/Pump_{i}"; + Assert.That(sink.WasPrimComposed(prim), Is.True, prim); + Assert.That(sink.IsPrimActive(prim), Is.True, prim); + } + Assert.That(sink.WasPrimComposed($"/Plant/Pumps/Pump_{ExpectedPumpCount + 1}"), + Is.False, "A pump the server does not simulate was composed."); + Assert.That(sink.WasPrimComposed("/Plant/Line1/Pumps/P_201"), Is.False, + "An aggregated line pump was composed into the twin."); + Assert.That(sink.WasPrimComposed("/Plant/Line1/RemotePump"), Is.False, + "The cross-server pump was composed into the twin."); }); } finally @@ -607,85 +635,246 @@ public async Task ProductionLineAggregatesPumpsAsync() } [Test] - public async Task CrossServerComponentIsComposedAsync() + public async Task ComponentBindingsAreDiscoverableAsync() { - // Cross-server composition (§5.14): the Line declares a component on another - // server; the connector composes its reference prim (federation drives its - // bindings only when a remote session factory is supplied — not here). - var sink = new MockUsdSink(); - var connector = new OpenUsdConnector(m_session!, sink); - await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); - try + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + System.Collections.Generic.List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); + + OpenUsdConnector.RepresentationInfo? pump = reps.Find(r => r.PrimPath == "/Plant/Pumps/Pump_1"); + OpenUsdConnector.RepresentationInfo? plant = reps.Find(r => r.PrimPath == "/Plant"); + + Assert.Multiple(() => { - Assert.That(sink.WasPrimComposed("/Plant/Line1/RemotePump"), Is.True, - "Cross-server component prim not composed."); - } - finally + Assert.That(reps, Has.Count.GreaterThanOrEqualTo(2), "Expected plant + pump representations."); + Assert.That(pump, Is.Not.Null); + Assert.That(pump!.Components, Has.Count.EqualTo(2), "Pump should have 2 (1:1) component bindings."); + Assert.That(plant, Is.Not.Null, "The plant aggregation representation was not discovered."); + // The plant composes one referenced pump prim per configured pump. + Assert.That(plant!.Components.Exists(c => c.Cardinality == OpenUsdCardinality.Many), Is.True); + }); + } + + /// + /// Every configured pump has to be a twin in its own right: its own prim, + /// discoverable through the registry, and driven by its own simulation. + /// The sample previously bound every pump to one hard-coded prim path and + /// registered only the first representation, so a server started with + /// --pumps N rendered a single machine. + /// + [Test] + public async Task EveryConfiguredPumpIsAnIndependentTwinAsync() + { + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + System.Collections.Generic.List reps = + await connector.DiscoverAllRepresentationsAsync(CancellationToken.None) + .ConfigureAwait(false); + + System.Collections.Generic.List pumps = + reps.FindAll(r => r.PrimPath != null && + r.PrimPath.StartsWith("/Plant/Pumps/", StringComparison.Ordinal) && + !r.PrimPath.EndsWith("/Impeller", StringComparison.Ordinal) && + !r.PrimPath.EndsWith("/Bearing", StringComparison.Ordinal)); + + Assert.Multiple(() => { - await connector.StopAsync().ConfigureAwait(false); + Assert.That(pumps, Has.Count.EqualTo(ExpectedPumpCount), + "Every configured pump must publish a discoverable representation."); + Assert.That( + pumps.ConvertAll(r => r.PrimPath).Distinct().Count(), + Is.EqualTo(pumps.Count), + "Two pumps must never share a prim."); + foreach (OpenUsdConnector.RepresentationInfo pump in pumps) + { + string primPath = pump.PrimPath!; + Assert.That(pump.Bindings, Is.Not.Empty, primPath); + Assert.That( + pump.Bindings.TrueForAll(b => + b.PrimPath != null && + b.PrimPath.StartsWith(primPath, StringComparison.Ordinal)), + Is.True, + primPath + " has a binding that targets another pump's prim."); + } + }); + + // The shaft angle is integrated per pump from its own phase-shifted + // duty point, so two pumps can never report the same angle. + System.Collections.Generic.List angles = []; + foreach (OpenUsdConnector.RepresentationInfo pump in pumps) + { + OpenUsdConnector.BindingInfo? shaft = pump.Bindings.Find( + b => b.PropertyName == "xformOp:rotateZ" && + b.PrimPath != null && + b.PrimPath.EndsWith("/Impeller", StringComparison.Ordinal)); + Assert.That(shaft, Is.Not.Null, pump.PrimPath + " has no shaft binding."); + DataValue value = await m_session!.ReadValueAsync( + shaft!.SourceNodeId, CancellationToken.None).ConfigureAwait(false); + Assert.That(value.WrappedValue.TryGetValue(out double angle), Is.True); + angles.Add(angle); } + + Assert.That(angles.Distinct().Count(), Is.EqualTo(angles.Count), + "Every pump must integrate its own shaft angle."); } + /// + /// Asserts that every prim a transform binding targets declares the op the + /// connector actually authors in its xformOpOrder. + /// A Translation, Rotation or Scale render target resolves to a single + /// xformOp:transform matrix; every other xformOp: property is + /// authored under its own name. USD only evaluates ops named in + /// xformOpOrder, and the list is uniform, so a connector cannot add + /// itself to it from a stronger layer. When the asset named the wrong op the + /// value was silently discarded, which parked every composed pump on the + /// origin - the hall rendered N stacked machines and looked like it held one. + /// [Test] - [Ignore("Blocked by OPCFoundation/UA-.NETStandard#4061: a runtime-added child is " - + "intermittently missing from a client Browse after CreateNodeAsync/AddNodeAsync + " - + "GeneralModelChange, so the connector does not reliably observe the dynamically added " - + "pump. Server-side state is correct; the browse is intermittently served stale. " - + "Re-enable once the upstream browse-consistency issue is fixed.")] - public async Task DynamicPumpIsComposedThenDeactivatedAsync() + public async Task TransformBindingsTargetDeclaredXformOpsAsync() { - // Dynamic composition (§5.13): the server periodically adds then removes a - // pump, emitting model-change events; the connector reconciles the prim - // (composed active on add, active=false on remove). - var sink = new MockUsdSink(); - var connector = new OpenUsdConnector(m_session!, sink); - await connector.StartAsync(CancellationToken.None).ConfigureAwait(false); + var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); + string cacheDir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "PumpXformOps", System.IO.Path.GetRandomFileName()); try { - const string dyn = "/Plant/Line1/Pumps/P_203"; - bool appeared = await PollAsync( - () => sink.WasPrimComposed(dyn) && sink.IsPrimActive(dyn), TimeSpan.FromSeconds(20)) - .ConfigureAwait(false); - Assert.That(appeared, Is.True, "Dynamically added pump prim was not composed."); + System.Collections.Generic.List assets = + await connector.FetchServedAssetsAsync(cacheDir, CancellationToken.None).ConfigureAwait(false); + OpenUsdConnector.FetchedAsset? component = assets.Find(a => a.Identifier == "pump.usda"); + Assert.That(component, Is.Not.Null, "pump.usda was not served."); + string layer = System.IO.File.ReadAllText(component!.LocalPath); - bool deactivated = await PollAsync( - () => sink.WasPrimComposed(dyn) && !sink.IsPrimActive(dyn), TimeSpan.FromSeconds(20)) - .ConfigureAwait(false); - Assert.That(deactivated, Is.True, "Dynamically removed pump prim was not deactivated."); + OpenUsdConnector.RepresentationInfo? pump = await PumpRepAsync(connector).ConfigureAwait(false); + Assert.That(pump, Is.Not.Null); + + System.Collections.Generic.List transforms = + pump!.Bindings.FindAll(b => b.PropertyName != null && + b.PropertyName.StartsWith("xformOp:", StringComparison.Ordinal)); + Assert.That(transforms, Is.Not.Empty, "The pump publishes no transform bindings."); + + Assert.Multiple(() => + { + foreach (OpenUsdConnector.BindingInfo binding in transforms) + { + // Bindings address the composed stage (/Plant/Pumps/Pump_1/...); + // the asset authors the same prims under its own root (/Pump/...). + string assetPath = "/Pump" + binding.PrimPath![pump.PrimPath!.Length..]; + string authored = AuthoredOpFor(binding.PropertyName!); + Assert.That( + DeclaredXformOps(layer, assetPath), + Does.Contain(authored), + $"{assetPath} is bound to {binding.PropertyName}, which a connector authors as " + + $"{authored}, but it does not list {authored} in xformOpOrder - so USD discards " + + "every value written to it."); + } + }); } finally { - await connector.StopAsync().ConfigureAwait(false); + if (System.IO.Directory.Exists(cacheDir)) + { + System.IO.Directory.Delete(cacheDir, recursive: true); + } } } - [Test] - public async Task ComponentBindingsAreDiscoverableAsync() + /// + /// Returns the xform op a connector authors for a bound op name. Translation, + /// rotation and scale are accumulated into one matrix so that the op order + /// never has to be rewritten from a stronger layer; anything else, such as the + /// scalar xformOp:rotateZ a shaft uses, is authored under its own name. + /// + private static string AuthoredOpFor(string propertyName) { - var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); - System.Collections.Generic.List reps = - await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); + return propertyName is "xformOp:translate" or "xformOp:rotateXYZ" or "xformOp:scale" + ? "xformOp:transform" + : propertyName; + } - OpenUsdConnector.RepresentationInfo? pump = reps.Find(r => r.PrimPath == "/Plant/Pumps/P101"); - OpenUsdConnector.RepresentationInfo? line = reps.Find(r => r.PrimPath == "/Plant/Line1"); + /// + /// Reads the xformOpOrder declared on a prim in a USD text layer. + /// + private static System.Collections.Generic.List DeclaredXformOps( + string layer, string primPath) + { + var names = new System.Collections.Generic.List(); + var openedAt = new System.Collections.Generic.List(); + var ops = new System.Collections.Generic.List(); + string? pending = null; + int depth = 0; + bool collecting = false; + + foreach (string line in layer.Split('\n')) + { + string text = line.Trim(); + bool inTarget = names.Count > 0 && + string.Equals("/" + string.Join("/", names), primPath, StringComparison.Ordinal); - Assert.Multiple(() => + if (text.StartsWith("def ", StringComparison.Ordinal) || + text.StartsWith("over ", StringComparison.Ordinal) || + text.StartsWith("class ", StringComparison.Ordinal)) + { + pending = QuotedTokens(text).FirstOrDefault(); + } + + // The op list may wrap over several lines, and the declaration itself + // contains a bracket pair ("uniform token[]"), so only the text after + // the assignment decides whether the list is already closed. + if (inTarget && text.Contains("xformOpOrder", StringComparison.Ordinal)) + { + int assign = text.IndexOf('=', StringComparison.Ordinal); + string tail = assign >= 0 ? text[(assign + 1)..] : text; + ops.AddRange(QuotedTokens(tail)); + collecting = !tail.Contains(']', StringComparison.Ordinal); + } + else if (collecting) + { + ops.AddRange(QuotedTokens(text)); + collecting = !text.Contains(']', StringComparison.Ordinal); + } + + foreach (char c in line) + { + if (c == '{') + { + depth++; + if (pending != null) + { + names.Add(pending); + openedAt.Add(depth); + pending = null; + } + } + else if (c == '}') + { + if (openedAt.Count > 0 && openedAt[^1] == depth) + { + names.RemoveAt(names.Count - 1); + openedAt.RemoveAt(openedAt.Count - 1); + } + depth--; + } + } + } + + return ops; + } + + /// + /// Splits the double-quoted tokens out of a line of USD text. + /// + private static System.Collections.Generic.IEnumerable QuotedTokens(string line) + { + string[] parts = line.Split('"'); + for (int i = 1; i < parts.Length; i += 2) { - Assert.That(reps, Has.Count.GreaterThanOrEqualTo(2), "Expected pump + line representations."); - Assert.That(pump, Is.Not.Null); - Assert.That(pump!.Components, Has.Count.EqualTo(2), "Pump should have 2 (1:1) component bindings."); - Assert.That(line, Is.Not.Null); - // Line: a Many aggregation + a cross-server component. - Assert.That(line!.Components.Exists(c => c.Cardinality == OpenUsdCardinality.Many), Is.True); - Assert.That(line.Components.Exists(c => !string.IsNullOrEmpty(c.ComponentEndpointUrl)), Is.True); - }); + yield return parts[i]; + } } private static async Task PumpRepAsync(OpenUsdConnector connector) { System.Collections.Generic.List all = await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); - return all.Find(r => r.PrimPath == "/Plant/Pumps/P101"); + return all.Find(r => r.PrimPath == "/Plant/Pumps/Pump_1"); } private static async Task PollAsync(Func condition, TimeSpan timeout) @@ -706,7 +895,7 @@ private static async Task PollAsync(Func condition, TimeSpan timeout public async Task ServedAssetsAreFetchedVerifiedAndCachedAsync() { // §5.15 asset content delivery: the server serves its USD layer closure - // (Plant.usda RootLayer + pump.usda + remote-pump.usda) via Part 5 FileType; + // (Plant.usda RootLayer + pump.usda) via Part 5 FileType; // the connector streams, verifies each digest, and caches them locally. var connector = new OpenUsdConnector(m_session!, new MockUsdSink()); string cacheDir = System.IO.Path.Combine( @@ -719,12 +908,10 @@ public async Task ServedAssetsAreFetchedVerifiedAndCachedAsync() OpenUsdConnector.FetchedAsset? root = assets.Find(a => a.Kind == OpenUsdAssetKind.RootLayer); Assert.Multiple(() => { - Assert.That(assets, Has.Count.EqualTo(3), "Expected 3 served layers."); + Assert.That(assets, Has.Count.EqualTo(2), "Expected 2 served layers."); Assert.That(assets.Exists(a => a.Identifier == "Plant.usda" && a.Kind == OpenUsdAssetKind.RootLayer), Is.True, "Plant.usda RootLayer not served."); Assert.That(assets.Exists(a => a.Identifier == "pump.usda"), Is.True, "pump.usda not served."); - Assert.That(assets.Exists(a => a.Identifier == "remote-pump.usda"), Is.True, - "remote-pump.usda not served."); Assert.That(assets.TrueForAll(a => a.DigestVerified), Is.True, "A served layer failed digest verification."); Assert.That(assets.TrueForAll(a =>