From b51edfa67c46d9ed50d6fc9277b430b342744cd8 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 08:01:19 +0200 Subject: [PATCH 1/8] Add a PumpX-2000 datasheet and diagrams to the pump sample Give the PumpDeviceIntegrationServer sample an official-style product datasheet for its simulated asset and align the server to it, so the document and the running address space cannot drift apart. New DATASHEET.md specifies the fictitious SimPump Corp PumpX-2000: nameplate mapped to OPC UA browse paths and namespaces, best-efficiency duty point, characteristic curves, performance table, operating limits, motor and materials data, instrument list, alarm trip points, the deterministic simulation profile, and a Mermaid process schematic. The server now implements those numbers. PumpDatasheet.cs holds the datasheet as compile-time constants; MaterialiseNameplate adds the 19 optional identification properties through the generator-emitted AddXxx(context) helpers so each carries its declared browse name, namespace and DataType. Both pumps become units of the same product (SN-001, SN-002) rather than two unrelated vendors. The simulation is rebuilt around the datasheet curves: volumetric flow is the only independent variable and differential pressure, mass flow, efficiency and shaft power are derived from it, so P = dp * Q / eta holds at every tick. Previously each measurement was an independent sine and the published duty point was not physically self-consistent (5 kW of shaft power at 0.05 kg/s and 2 bar). Engineering ranges follow the datasheet, OverTempAlarm reports the BearingTemperature chain instead of a boolean, and the Cavitation and MotorOverheat supervision states are derived from suction level and bearing temperature with hysteresis. The sample README gains a simulated-device summary, a link to the datasheet, and three Mermaid diagrams: address space, startup and hosting sequence, and the simulation and alarm dataflow. PumpDatasheetConformanceTests pins the nameplate, engineering ranges, trip points, value envelope and hydraulic consistency against the datasheet, and the pump node-surface baseline covers the new identification properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba --- docs/DeviceIntegration.md | 13 +- docs/SourceGeneratedNodeManagers.md | 15 +- .../PumpDeviceIntegrationServer/DATASHEET.md | 321 ++++++++++++++++++ .../PumpDeviceIntegrationServer/Program.cs | 67 +++- .../PumpDatasheet.cs | 220 ++++++++++++ .../PumpNodeManager.Configure.cs | 213 +++++++++--- .../PumpNodeManager.cs | 43 +++ samples/PumpDeviceIntegrationServer/README.md | 159 ++++++++- .../PumpDatasheetConformanceTests.cs | 290 ++++++++++++++++ .../PumpInstanceNodeIdRegressionTests.cs | 18 + 10 files changed, 1292 insertions(+), 67 deletions(-) create mode 100644 samples/PumpDeviceIntegrationServer/DATASHEET.md create mode 100644 samples/PumpDeviceIntegrationServer/PumpDatasheet.cs create mode 100644 tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs diff --git a/docs/DeviceIntegration.md b/docs/DeviceIntegration.md index a4a14ede0a..dbbae72e7b 100644 --- a/docs/DeviceIntegration.md +++ b/docs/DeviceIntegration.md @@ -35,7 +35,11 @@ plugs it together. The running example is `samples/PumpDeviceIntegrationServer`, a companion-spec server with full simulation and a second generated -`PumpType` configured through the topology-element builder. +`PumpType` configured through the topology-element builder. 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 @@ -93,8 +97,11 @@ builder.Services ushort diNamespaceIndex = manager.DiNamespaceIndex; pump.WithIdentificationGroup(group => group.Configure(node => node.WithProperty( - new QualifiedName("SerialNumber", diNamespaceIndex), - Variant.From("SN-DI-2")))); + new QualifiedName("Manufacturer", diNamespaceIndex), + Variant.From(new LocalizedText("SimPump Corp"))) + .WithProperty( + new QualifiedName("SerialNumber", diNamespaceIndex), + Variant.From("SN-002")))); }); ``` diff --git a/docs/SourceGeneratedNodeManagers.md b/docs/SourceGeneratedNodeManagers.md index 6cb4cea0ce..02c28025e0 100644 --- a/docs/SourceGeneratedNodeManagers.md +++ b/docs/SourceGeneratedNodeManagers.md @@ -607,12 +607,12 @@ builder.Variable("Pumps/Pump #1/Operational/Measurements/FluidTemperatur .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 @@ -631,14 +631,19 @@ hatch. ```csharp builder.Node("Pumps/Pump #1/Identification") - .WithProperty("Manufacturer", "SimPump Corp") - .WithProperty("Model", "PumpX-2000") + .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/samples/PumpDeviceIntegrationServer/DATASHEET.md b/samples/PumpDeviceIntegrationServer/DATASHEET.md new file mode 100644 index 0000000000..0535ef14fa --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/DATASHEET.md @@ -0,0 +1,321 @@ +# 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. + +--- + +## 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 +two 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/Program.cs b/samples/PumpDeviceIntegrationServer/Program.cs index 695a580430..118edb8040 100644 --- a/samples/PumpDeviceIntegrationServer/Program.cs +++ b/samples/PumpDeviceIntegrationServer/Program.cs @@ -82,25 +82,78 @@ ctx.TopologyElement(pumpState.NodeId); ushort diNamespaceIndex = ctx.Manager.DiNamespaceIndex; + ushort machineryNamespaceIndex = (ushort)manager.Server.NamespaceUris.GetIndex( + Opc.Ua.Machinery.Namespaces.Machinery); + + // Nameplate of unit SN-002 as published in DATASHEET.md. The + // properties are materialised by the node manager; the topology + // element builder only assigns their values. pump.WithIdentificationGroup(id => id.Configure(node => node.WithProperty( new QualifiedName("Manufacturer", diNamespaceIndex), - Variant.From(new LocalizedText("Acme Pumps Inc."))) + Variant.From( + new LocalizedText(PumpDatasheet.Nameplate.Manufacturer))) + .WithProperty( + new QualifiedName("ManufacturerUri", diNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.ManufacturerUri)) .WithProperty( new QualifiedName("Model", diNamespaceIndex), - Variant.From(new LocalizedText("PumpX-2000 (declarative)"))) + Variant.From(new LocalizedText(PumpDatasheet.Nameplate.Model))) .WithProperty( - new QualifiedName("SerialNumber", diNamespaceIndex), - Variant.From("SN-DI-2")) + new QualifiedName("ProductCode", diNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.ProductCode)) .WithProperty( new QualifiedName("DeviceClass", diNamespaceIndex), - Variant.From("Pump")) + Variant.From(PumpDatasheet.Nameplate.DeviceClass)) .WithProperty( new QualifiedName("HardwareRevision", diNamespaceIndex), - Variant.From("1.0")) + Variant.From(PumpDatasheet.Nameplate.HardwareRevision)) .WithProperty( new QualifiedName("SoftwareRevision", diNamespaceIndex), - Variant.From("2.5.3")))); + Variant.From(PumpDatasheet.Nameplate.SoftwareRevision)) + .WithProperty( + new QualifiedName("SerialNumber", diNamespaceIndex), + Variant.From("SN-002")) + .WithProperty( + new QualifiedName("ProductInstanceUri", diNamespaceIndex), + Variant.From( + PumpDatasheet.Nameplate.ProductInstanceUriPrefix + "SN-002")) + .WithProperty( + new QualifiedName("AssetId", diNamespaceIndex), + Variant.From("PMP-1002")) + .WithProperty( + new QualifiedName("ComponentName", diNamespaceIndex), + Variant.From(new LocalizedText("Feed Pump B"))) + .WithProperty( + new QualifiedName("Location", machineryNamespaceIndex), + Variant.From("Plant 1 / Utility Skid / Bay 4")) + .WithProperty( + new QualifiedName("YearOfConstruction", machineryNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.YearOfConstruction)) + .WithProperty( + new QualifiedName("MonthOfConstruction", machineryNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.MonthOfConstruction)) + .WithProperty( + new QualifiedName("DayOfConstruction", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.DayOfConstruction)) + .WithProperty( + new QualifiedName("ArticleNumber", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.ArticleNumber)) + .WithProperty( + new QualifiedName("OrderProductCode", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.OrderProductCode)) + .WithProperty( + new QualifiedName("TypeOfProduct", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.TypeOfProduct)) + .WithProperty( + new QualifiedName("Supplier", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.Supplier)) + .WithProperty( + new QualifiedName("CountryOfOrigin", pumpsNamespaceIndex), + Variant.From(PumpDatasheet.Nameplate.CountryOfOrigin)) + .WithProperty( + new QualifiedName("FabricationNumber", pumpsNamespaceIndex), + Variant.From("F-2025-0002")))); // Demonstrate the non-typed WithFunctionalGroup(QualifiedName) // builder for ad-hoc groups not covered by the 8 well-known diff --git a/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs b/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs new file mode 100644 index 0000000000..5b1381f369 --- /dev/null +++ b/samples/PumpDeviceIntegrationServer/PumpDatasheet.cs @@ -0,0 +1,220 @@ +/* ======================================================================== + * 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); + + /// + /// 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/PumpNodeManager.Configure.cs b/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs index 8eebec61f8..937743386f 100644 --- a/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs +++ b/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs @@ -117,18 +117,63 @@ private void RegisterPumpSimulation( } /// - /// Configures the identification values demonstrated by the - /// hand-wired first pump. + /// Configures the nameplate of the hand-wired first pump with the + /// identification data published in DATASHEET.md for unit + /// SN-001. The properties themselves are materialised by + /// ; this method + /// only assigns their values through the fluent builder. /// /// The active fluent builder. private static void WithIdentification(INodeManagerBuilder builder) { builder.Node("Pump #1/Identification") - .WithProperty("Manufacturer", "SimPump Corp") + .WithProperty( + "Manufacturer", + new LocalizedText(PumpDatasheet.Nameplate.Manufacturer)) + .WithProperty( + "ManufacturerUri", + PumpDatasheet.Nameplate.ManufacturerUri) + .WithProperty( + "Model", + new LocalizedText(PumpDatasheet.Nameplate.Model)) + .WithProperty("ProductCode", PumpDatasheet.Nameplate.ProductCode) + .WithProperty("DeviceClass", PumpDatasheet.Nameplate.DeviceClass) + .WithProperty( + "HardwareRevision", + PumpDatasheet.Nameplate.HardwareRevision) + .WithProperty( + "SoftwareRevision", + PumpDatasheet.Nameplate.SoftwareRevision) .WithProperty("SerialNumber", "SN-001") .WithProperty( "ProductInstanceUri", - "urn:simdevice:SimPump:PumpX-2000:SN-001"); + PumpDatasheet.Nameplate.ProductInstanceUriPrefix + "SN-001") + .WithProperty("AssetId", "PMP-1001") + .WithProperty("ComponentName", new LocalizedText("Feed Pump A")) + .WithProperty("Location", "Plant 1 / Utility Skid / Bay 3") + .WithProperty( + "YearOfConstruction", + PumpDatasheet.Nameplate.YearOfConstruction) + .WithProperty( + "MonthOfConstruction", + PumpDatasheet.Nameplate.MonthOfConstruction) + .WithProperty( + "DayOfConstruction", + PumpDatasheet.Nameplate.DayOfConstruction) + .WithProperty( + "ArticleNumber", + PumpDatasheet.Nameplate.ArticleNumber) + .WithProperty( + "OrderProductCode", + PumpDatasheet.Nameplate.OrderProductCode) + .WithProperty( + "TypeOfProduct", + PumpDatasheet.Nameplate.TypeOfProduct) + .WithProperty("Supplier", PumpDatasheet.Nameplate.Supplier) + .WithProperty( + "CountryOfOrigin", + PumpDatasheet.Nameplate.CountryOfOrigin) + .WithProperty("FabricationNumber", "F-2025-0001"); } private PumpSimulationState CreatePumpSimulation( @@ -142,50 +187,50 @@ private PumpSimulationState CreatePumpSimulation( builder, measurements.DifferentialPressure!.NodeId, EngineeringUnits.Pascal, - min: 0, - max: 1_000_000, + min: PumpDatasheet.Ranges.DifferentialPressureMin, + max: PumpDatasheet.Ranges.DifferentialPressureMax, out IValueUpdater pressure); AddMeasurement( builder, measurements.FluidTemperature!.NodeId, EngineeringUnits.Kelvin, - min: 233.15, - max: 473.15, + min: PumpDatasheet.Ranges.FluidTemperatureMin, + max: PumpDatasheet.Ranges.FluidTemperatureMax, out IValueUpdater fluidTemperature); AddMeasurement( builder, measurements.BearingTemperature!.NodeId, EngineeringUnits.Kelvin, - min: 233.15, - max: 473.15, + min: PumpDatasheet.Ranges.BearingTemperatureMin, + max: PumpDatasheet.Ranges.BearingTemperatureMax, out IValueUpdater bearingTemperature); AddMeasurement( builder, measurements.PumpPowerInput!.NodeId, EngineeringUnits.Watt, - min: 0, - max: 50_000, + min: PumpDatasheet.Ranges.PumpPowerInputMin, + max: PumpDatasheet.Ranges.PumpPowerInputMax, out IValueUpdater power); AddMeasurement( builder, measurements.MassFlow!.NodeId, EngineeringUnits.KilogramsPerSecond, - min: 0, - max: 1.0, + min: PumpDatasheet.Ranges.MassFlowMin, + max: PumpDatasheet.Ranges.MassFlowMax, out IValueUpdater flow); AddMeasurement( builder, measurements.PumpEfficiency!.NodeId, EngineeringUnits.Percent, - min: 0, - max: 100, + min: PumpDatasheet.Ranges.PumpEfficiencyMin, + max: PumpDatasheet.Ranges.PumpEfficiencyMax, out IValueUpdater efficiency); AddMeasurement( builder, measurements.Level!.NodeId, EngineeringUnits.Metre, - min: 0, - max: 10, + min: PumpDatasheet.Ranges.LevelMin, + max: PumpDatasheet.Ranges.LevelMax, out IValueUpdater level); builder.Variable(measurements.NumberOfStarts!.NodeId) @@ -198,14 +243,18 @@ private PumpSimulationState CreatePumpSimulation( INodeBuilder events = pumpBuilder.Components().Events(); + // The alarm reports the bearing-temperature chain, so its + // source node is the measurement and its limits are the + // datasheet trip points. IAlarmBuilder overTempAlarm = events .CreateLimitAlarm( new QualifiedName("OverTempAlarm", pumpsNs)) .WithLimits( - highHigh: 373.15, - high: 363.15, - low: 283.15, - lowLow: 273.15) + highHigh: PumpDatasheet.TripPoints.BearingTemperatureHighHigh, + high: PumpDatasheet.TripPoints.BearingTemperatureHigh, + low: PumpDatasheet.TripPoints.BearingTemperatureLow, + lowLow: PumpDatasheet.TripPoints.BearingTemperatureLowLow) + .MonitorVariable(measurements.BearingTemperature) .OnAcknowledge((ctx, c, eventId, comment) => ServiceResult.Good); events.Components().SupervisionProcessFluid() @@ -215,7 +264,6 @@ private PumpSimulationState CreatePumpSimulation( .Components().SupervisionPumpOperation() .Components().MotorOverheat() .Bind(out IValueUpdater motorOverheat); - overTempAlarm.MonitorVariable(motorOverheatBuilder.Node); motorOverheatBuilder.ActivatesAlarm(overTempAlarm); return new PumpSimulationState( @@ -291,7 +339,8 @@ public PumpSimulationState( IValueUpdater cavitation, IValueUpdater motorOverheat) { - m_phaseOffset = profileIndex * 17L; + m_phaseOffset = + profileIndex * PumpDatasheet.Simulation.PhaseOffsetTicks; m_pressure = pressure; m_fluidTemperature = fluidTemperature; m_bearingTemperature = bearingTemperature; @@ -314,41 +363,76 @@ public void Advance(long tick) Publish(tick, publishAll: false); } + /// + /// Publishes one simulation step. Volumetric flow is the only + /// independent variable; head, differential pressure, mass + /// flow, efficiency and shaft power all follow from the + /// datasheet characteristic curves, so the published values + /// stay mutually consistent. + /// private void Publish(long tick, bool publishAll) { long localTick = tick + m_phaseOffset; - m_pressure.SetValue( - 200_000.0 + (50_000.0 * Math.Sin(localTick * 0.03))); + + double flow = PumpDatasheet.Hydraulics.RatedFlow * + (1.0 + (PumpDatasheet.Simulation.FlowModulation * + Math.Sin(localTick * PumpDatasheet.Simulation.FlowRate))); + double head = Head(flow); + double efficiency = Efficiency(flow); + double massFlow = PumpDatasheet.Hydraulics.FluidDensity * + flow / 3600.0; + double differentialPressure = + PumpDatasheet.Hydraulics.FluidDensity * + PumpDatasheet.Hydraulics.GravitationalAcceleration * head; + double shaftPower = differentialPressure * (flow / 3600.0) / + (efficiency / 100.0); + double bearingTemperature = + PumpDatasheet.Simulation.BearingTemperatureBase + + (PumpDatasheet.Simulation.BearingTemperatureLoadRise * + shaftPower / PumpDatasheet.Hydraulics.RatedShaftPower) + + CoolingFaultExcursion(localTick); + double level = PumpDatasheet.Simulation.LevelNominal + + (PumpDatasheet.Simulation.LevelAmplitude * + Math.Sin(localTick * PumpDatasheet.Simulation.LevelRate)); + + m_pressure.SetValue(differentialPressure); + m_flow.SetValue(massFlow); + m_efficiency.SetValue(efficiency); + m_power.SetValue(shaftPower); + m_bearingTemperature.SetValue(bearingTemperature); + m_level.SetValue(level); m_fluidTemperature.SetValue( - 313.15 + (5.0 * Math.Sin(localTick * 0.01))); - m_bearingTemperature.SetValue( - 333.15 + (8.0 * Math.Cos(localTick * 0.008))); - m_power.SetValue( - 5_000.0 + (500.0 * Math.Sin(localTick * 0.02))); - m_flow.SetValue( - 0.05 + (0.005 * Math.Cos(localTick * 0.04))); - m_efficiency.SetValue( - 75.0 + (10.0 * Math.Sin(localTick * 0.015))); - m_level.SetValue( - 2.5 + (0.5 * Math.Sin(localTick * 0.02))); - - uint numberOfStarts = checked((uint)(localTick / 3_600)); + PumpDatasheet.Simulation.FluidTemperatureNominal + + (PumpDatasheet.Simulation.FluidTemperatureAmplitude * + Math.Sin( + localTick * + PumpDatasheet.Simulation.FluidTemperatureRate))); + + uint numberOfStarts = checked((uint)(localTick / + PumpDatasheet.Simulation.StartIntervalTicks)); if (publishAll || numberOfStarts != m_currentNumberOfStarts) { m_currentNumberOfStarts = numberOfStarts; m_numberOfStarts.SetValue(numberOfStarts); } - long cavitationCycle = localTick % 40; - bool cavitation = cavitationCycle >= 32 && cavitationCycle < 36; + // Cavitation is reported once the suction head falls below + // the NPSH requirement, with hysteresis so the supervision + // state does not chatter at the threshold. + bool cavitation = m_currentCavitation + ? level < PumpDatasheet.TripPoints.CavitationClearLevel + : level < PumpDatasheet.TripPoints.CavitationSetLevel; if (publishAll || cavitation != m_currentCavitation) { m_currentCavitation = cavitation; m_cavitation.SetValue(cavitation); } - long overheatCycle = localTick % 64; - bool motorOverheat = overheatCycle >= 56 && overheatCycle < 60; + bool motorOverheat = m_currentMotorOverheat + ? bearingTemperature >= + PumpDatasheet.TripPoints.MotorOverheatClear + : bearingTemperature >= + PumpDatasheet.TripPoints.MotorOverheatSet; if (publishAll || motorOverheat != m_currentMotorOverheat) { m_currentMotorOverheat = motorOverheat; @@ -356,6 +440,49 @@ private void Publish(long tick, bool publishAll) } } + /// + /// Head curve of the datasheet, in m for a flow in m³/h. + /// + private static double Head(double flow) + { + return PumpDatasheet.Hydraulics.ShutoffHead - + (PumpDatasheet.Hydraulics.HeadCurveCoefficient * flow * flow); + } + + /// + /// Efficiency curve of the datasheet, in percent for a flow in + /// m³/h. + /// + private static double Efficiency(double flow) + { + double deviation = + (flow - PumpDatasheet.Hydraulics.RatedFlow) / + PumpDatasheet.Hydraulics.RatedFlow; + return PumpDatasheet.Hydraulics.RatedEfficiency * + (1.0 - (PumpDatasheet.Hydraulics.EfficiencyCurveFactor * + deviation * deviation)); + } + + /// + /// Bearing temperature rise in K caused by the periodic + /// bearing-cooling interruption documented in the datasheet. + /// The ramp crosses both the high and the high-high trip point + /// so a complete alarm cycle is observable. + /// + private static double CoolingFaultExcursion(long tick) + { + long cycle = tick % PumpDatasheet.Simulation.CoolingFaultPeriodTicks; + if (cycle < PumpDatasheet.Simulation.CoolingFaultOnsetTick) + { + return 0.0; + } + long rampTicks = PumpDatasheet.Simulation.CoolingFaultPeriodTicks - + PumpDatasheet.Simulation.CoolingFaultOnsetTick; + return PumpDatasheet.Simulation.CoolingFaultRise * + (cycle - PumpDatasheet.Simulation.CoolingFaultOnsetTick) / + rampTicks; + } + private readonly long m_phaseOffset; private readonly IValueUpdater m_pressure; private readonly IValueUpdater m_fluidTemperature; diff --git a/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs b/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs index 62fa5be290..c1a01e3a51 100644 --- a/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs +++ b/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs @@ -273,6 +273,8 @@ await AddRootNotifierAsync(pump, cancellationToken) private void MaterialisePumpOptionalChildren( PumpState pump) { + MaterialiseNameplate(pump.Identification!); + pump.AddOperational(SystemContext); OperationalGroupState operational = pump.Operational!; operational.AddMeasurements(SystemContext); @@ -318,6 +320,47 @@ private void MaterialisePumpOptionalChildren( pump.AddMaintenance(SystemContext); } + /// + /// Materialises the optional nameplate properties that carry the + /// PumpX-2000 datasheet identification data. Manufacturer + /// and SerialNumber are mandatory on + /// PumpIdentificationType and are already created by the + /// generated factory; every other field is optional and is added + /// here through the generator-emitted AddXxx(context) + /// helpers so each property keeps the browse name, namespace and + /// DataType declared by the DI, Machinery and Pumps models. The + /// values themselves are assigned by the fluent + /// WithProperty wiring (Pump #1) and by the topology-element + /// builder (Pump #2). + /// + private void MaterialiseNameplate(PumpIdentificationState identification) + { + // OPC 10000-100 (DI) nameplate. + identification.AddManufacturerUri(SystemContext); + identification.AddModel(SystemContext); + identification.AddProductCode(SystemContext); + identification.AddDeviceClass(SystemContext); + identification.AddHardwareRevision(SystemContext); + identification.AddSoftwareRevision(SystemContext); + identification.AddProductInstanceUri(SystemContext); + identification.AddAssetId(SystemContext); + identification.AddComponentName(SystemContext); + + // OPC 40001-1 (Machinery) nameplate. + identification.AddLocation(SystemContext); + identification.AddYearOfConstruction(SystemContext); + identification.AddMonthOfConstruction(SystemContext); + + // OPC 40223 (Pumps) nameplate. + identification.AddDayOfConstruction(SystemContext); + identification.AddArticleNumber(SystemContext); + identification.AddOrderProductCode(SystemContext); + identification.AddTypeOfProduct(SystemContext); + identification.AddSupplier(SystemContext); + identification.AddCountryOfOrigin(SystemContext); + identification.AddFabricationNumber(SystemContext); + } + /// /// Partial wired by the Configure.cs sibling. /// diff --git a/samples/PumpDeviceIntegrationServer/README.md b/samples/PumpDeviceIntegrationServer/README.md index d3f9031a4e..fbb5a41ec4 100644 --- a/samples/PumpDeviceIntegrationServer/README.md +++ b/samples/PumpDeviceIntegrationServer/README.md @@ -11,6 +11,33 @@ shipped under `src/Opc.Ua.Server/Fluent/`. Each extension is documented in [Source-generated NodeManagers — Building richer node managers](../../docs/SourceGeneratedNodeManagers.md#building-richer-node-managers--the-fluent-extension-surface). +The simulated asset is a *SimPump Corp PumpX-2000*. Its nameplate, +engineering ranges, alarm trip points and simulation profile are all +published in [`DATASHEET.md`](./DATASHEET.md) — an official-style product +datasheet that the server is aligned to and that +`tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs` asserts against, +so document and address space cannot drift apart. + +## The simulated device + +| | | +|---|---| +| Manufacturer / model | SimPump Corp PumpX-2000 | +| Type | Single-stage end-suction centrifugal process pump | +| Rated duty point | 25 m³/h (6.93 kg/s) at 25.5 m head (2.5 bar Δp) | +| Rated efficiency / shaft power | 72 % / 2.41 kW | +| Motor | 3.0 kW, 400 V 3~ 50 Hz, 2900 min⁻¹ | +| Units in the address space | SN-001 (`Pump #1`), SN-002 (`Pump #2`) | +| Bearing-temperature trip points | 363.15 K high, 373.15 K high-high | +| Full specification | [`DATASHEET.md`](./DATASHEET.md) | + +Volumetric flow is the only independent variable in the simulation. +Head, differential pressure, mass flow, efficiency and shaft power all +follow from the datasheet characteristic curves +(`H(Q) = 32 − 0.0104·Q²`, `η(Q) = 72·(1 − 0.6·((Q−25)/25)²)`, +`P = ρ·g·Q·H/η`), so the published values are mutually consistent at +every tick rather than being independent sine waves. + ## Running the sample ```pwsh @@ -26,11 +53,15 @@ Sample console output: ``` info: Opc.Ua.Server.MasterNodeManager MasterNodeManager.Startup - NodeManagers=3 +info: Opc.Ua.Di.Server.DiNodeManager + Materialised 'Pump #1' (PumpType) under DeviceSet, NodeId=ns=4;s=5001_Pump #1. info: Pumps.PumpNodeManager Configuring PumpNodeManager fluent wiring... -info: Pumps.PumpNodeManager - PumpNodeManager: address space ready (10330 predefined nodes). -info: Opc.Ua.Server.StandardServer +info: Opc.Ua.Di.Server.DiNodeManager + PumpNodeManager: address space ready (10196 predefined nodes). +info: Opc.Ua.Di.Server.DiNodeManager + Materialised 'Pump #2' (PumpType) under DeviceSet, NodeId=ns=4;s=5001_Pump #2. +info: Opc.Ua.Server.Hosting.OpcUaServerHostedService OPC UA server listening at opc.tcp://localhost:62542/PumpDeviceIntegrationServer. ``` @@ -38,8 +69,10 @@ Browse to `Objects > DeviceSet > Pump #1` in any OPC UA client (e.g. UaExpert) to explore the simulated pump. A second declarative pump, `Pump #2`, is organized alongside it by the same `DeviceSet` — it demonstrates the DI hosting `ConfigureDevicesFor` flow and automatically -joins the same live simulation. Both pumps publish monitored data changes -every 250 ms, with deterministic phase offsets so their values do not move +joins the same live simulation. Both pumps are units of the same +PumpX-2000 product and differ only in serial number, asset id, component +name and installation bay. They publish monitored data changes every +250 ms, with deterministic phase offsets so their values do not move in lockstep. Subscribe to the `EventNotifier` attribute on either pump to receive alarm @@ -47,6 +80,97 @@ condition events when its simulated `MotorOverheat` state activates or clears. Each pump is also registered as a root notifier, so the same events are available from a subscription on the Server object. +## Address space + +```mermaid +flowchart TD + Objects["Objects"] + DeviceSet["DeviceSet
OPC 10000-100 (DI)"] + P1["Pump #1 · SN-001
PumpType, hand-wired"] + P2["Pump #2 · SN-002
PumpType, declarative"] + Id["Identification
21 nameplate properties"] + Op["Operational
OPC 40001-1 (Machinery)"] + Meas["Measurements"] + Ev["Events
EventNotifier"] + Maint["Maintenance"] + Diag["Diagnostics
ad-hoc FunctionalGroup"] + Vals["DifferentialPressure · FluidTemperature
BearingTemperature · PumpPowerInput
MassFlow · PumpEfficiency · Level
NumberOfStarts"] + Alarm["OverTempAlarm
NonExclusiveLimitAlarmType"] + SupF["SupervisionProcessFluid
└ Cavitation"] + SupP["SupervisionPumpOperation
└ MotorOverheat"] + + Objects -->|Organizes| DeviceSet + DeviceSet -->|Organizes| P1 + DeviceSet -->|Organizes| P2 + P1 -->|HasComponent| Id + P1 -->|HasComponent| Op + P1 -->|HasComponent| Ev + P1 -->|HasComponent| Maint + P2 -->|HasComponent| Diag + Op -->|HasComponent| Meas + Meas --> Vals + Ev -->|HasComponent| Alarm + Ev -->|HasComponent| SupF + Ev -->|HasComponent| SupP + P1 -.->|HasNotifier| Ev + Alarm -.->|SourceNode| Vals +``` + +`Pump #2` carries the identical `Identification` / `Operational` / +`Events` / `Maintenance` subtree; only its extra `Diagnostics` group is +drawn above, because that group is created by the non-typed +`WithFunctionalGroup(QualifiedName, ...)` overload rather than by the +model. + +## 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: AddNodeManager() + Host->>DI: ConfigureDevicesFor(...) + DI->>Factory: CreateAsync(server, configuration) + Factory->>NM: new PumpNodeManager(.., postSetupRunner) + NM->>NM: LoadPredefinedNodesAsync
AddOpcUaDi + Machinery + Pumps + NM->>NM: OnAddressSpaceReadyAsync + NM->>NM: ConfigureInstancesAsync → Pump #1 (PumpType) + NM->>NM: MaterialiseNameplate + optional children + NM->>NM: CreateFluentBuilder().Configure(Configure).Seal() + Note over NM: Seal starts the 250 ms simulation loop + NM->>Runner: post-setup pipeline + Runner->>NM: CreatePumpAsync("Pump #2") + NM->>NM: RegisterPumpSimulation(Pump #2) + Runner->>NM: WithIdentificationGroup / WithFunctionalGroup +``` + +## 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"]) + + Tick --> Adv --> Sim --> Curves --> Upd --> Vars --> MI + Sim --> Ovr --> Alarm --> Notif --> Ev + Sim --> Cav --> Vars + Vars -.->|SourceNode| Alarm +``` + ## Validating the address space The @@ -124,12 +248,14 @@ 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` | | One 250 ms simulation tick for all phase-shifted pumps | `Configure` → `AdvanceSimulation` | -| Limit alarm with thresholds and acknowledge handler via `CreateLimitAlarm(...).WithLimits(...)` | `CreatePumpSimulation` | +| Datasheet-driven simulation (one independent variable, derived values) | `PumpDatasheet.cs` + `PumpSimulationState.Publish` | +| Limit alarm with thresholds and acknowledge handler via `CreateLimitAlarm(...).WithLimits(...).MonitorVariable(...)` | `CreatePumpSimulation` | | Boolean supervision → reported alarm condition 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` | @@ -145,6 +271,8 @@ PumpDeviceIntegrationServer/ │ # + LoadPredefinedNodesAsync (multi-model) │ # + CreateAddressSpaceAsync (builder setup) ├── PumpNodeManager.Configure.cs # partial — fluent wiring + simulation tick +├── 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) @@ -182,12 +310,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. +- **Add a nameplate field**: materialise it with the generator-emitted + `AddXxx(context)` helper in `PumpNodeManager.MaterialiseNameplate`, + assign the value in `WithIdentification` (Pump #1) and in the + `WithIdentificationGroup` block of `Program.cs` (Pump #2), and add the + row to section 2 of the datasheet. - **Add a second pump**: two patterns are demonstrated in the sample. - **Hand-rolled** (used for `Pump #1`): in `PumpNodeManager.CreatePumpAsync`, create the generated `PumpState`, attach it to the DI `DeviceSet` with `Organizes`, and register it. The fluent `Configure.cs` then wires its measurements, alarms, and simulation by browse path. - **DI declarative** (used for `Pump #2`): in `Program.cs`, call `PumpNodeManager.CreatePumpAsync(...)` from a `ConfigureDevicesFor` block, wrap the generated `PumpState` with `ctx.TopologyElement(...)`, then configure the mandatory `Identification` group. `CreatePumpAsync` also registers the new instance with the shared simulation. @@ -205,6 +344,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/PumpDatasheetConformanceTests.cs b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs new file mode 100644 index 0000000000..0fbe7cd18a --- /dev/null +++ b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs @@ -0,0 +1,290 @@ +/* ======================================================================== + * 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.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.DiNamespaceIndex)); + } + + [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 void SimulatedValuesAreHydraulicallyConsistent() + { + 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; + 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; + + // Volumetric flow in m³/s from the published mass flow. + double volumeFlow = massFlow / FluidDensity; + double expectedPower = differentialPressure * volumeFlow / + (efficiency / 100.0); + deviation = System.Math.Abs(expectedPower - shaftPower) / shaftPower; + if (deviation <= ConsistencyTolerance) + { + break; + } + } + + 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 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 81551a326c..1a035f8243 100644 --- a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs @@ -115,9 +115,27 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/SupervisionPumpOperation/MotorOverheat/FalseState", "Events/SupervisionPumpOperation/MotorOverheat/TrueState", "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", "Maintenance", "Operational", "Operational/Measurements", From edb30f4544a78e946951a1f42b2ec89acd611b15 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 09:10:08 +0200 Subject: [PATCH 2/8] Guard the hydraulic-consistency test against incomplete samples Address PR review feedback on PumpDatasheetConformanceTests: the consistency check divided by the published efficiency and shaft power without checking them first, so a sample read before the simulation had published a coherent set of values would have produced NaN or Infinity and failed on startup timing rather than on the hydraulic model. Skip and retry an incomplete sample instead, and assert explicitly that a complete sample was observed before asserting the deviation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba --- .../PumpDatasheetConformanceTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs index f13872cb35..c0ac143ca3 100644 --- a/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpDatasheetConformanceTests.cs @@ -27,6 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System; using System.Collections.Generic; using System.Threading.Tasks; using NUnit.Framework; @@ -210,7 +211,7 @@ public void SimulatedValuesStayWithinTheDatasheetEnvelope() /// P = Δp · Q / η at every instant. /// [Test] - public void SimulatedValuesAreHydraulicallyConsistent() + public async Task SimulatedValuesAreHydraulicallyConsistentAsync() { MeasurementsState measurements = m_pump!.Operational!.Measurements!; @@ -219,6 +220,7 @@ public void SimulatedValuesAreHydraulicallyConsistent() // 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; @@ -226,17 +228,33 @@ public void SimulatedValuesAreHydraulicallyConsistent() 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 = System.Math.Abs(expectedPower - shaftPower) / shaftPower; + 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), @@ -282,6 +300,7 @@ private static void AssertWithin( 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; From b7e41379ddb83224d4686348ab73d49443507bc9 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 11:40:33 +0200 Subject: [PATCH 3/8] Render every configured pump in the OpenUSD twin The twin was hard-wired to a single machine: PumpPrimPath was the constant /Plant/Pumps/P101 and every pump's representation, component bindings and live bindings used it, so N pumps drove one prim. The signal Variables lived in single fields that each pump overwrote, ShaftAngle and AlarmActive both read m_primarySimulation - the first pump - and only the first representation was organised into the discovery registry. A server started with --pumps 4 rendered one pump, spun by pump 1 and alarmed by pump 4. Each pump now owns a PumpTwin: its prim path (/Plant/Pumps/), its signal Variables, its representation and its simulation. Every representation is registered in the Representations folder, and a plant-level representation on the DeviceSet declares one Many component binding scoped to PumpType, so a connector composes one full-fidelity pump.usda reference per configured pump. The arc is Reference rather than Instance because an instanceable prim turns its descendants into a shared prototype, which cannot carry per-pump rotation or colour. Each pump publishes its bay as a ThreeDCartesianCoordinates value bound to xformOp:translate, so any number of pumps lays out in a row without the stage authoring anything per pump. The binding set now covers the machine rather than four signals. Added: motor cooling fan, discharge-pressure and bearing-temperature gauge needles, a suction vessel whose liquid surface rides on the published level, suction-line tint from fluid temperature, mass-flow, efficiency and start-count readouts, and distinct cavitation and motor-overheat indications at the place on the machine where each fault is. Scales and offsets are derived from PumpDatasheet, so a datasheet change moves the needles with it. Efficiency is a readout rather than a colour: the DisplayColor ramp models a temperature. The geometry those bindings need is authored into the P101 master and regenerated into pump.usda and remote-pump.usda. Beacon and StatusLight move into the component asset, because signalling is now per machine. The master is left in the layer but deactivated: it is the generator's input, and rendering it too would show a phantom machine no OPC UA object drives. The OverTempAlarm condition is deliberately not bound. The fluent alarm builder leaves the condition's state children on their standard namespace-0 declaration NodeIds, so every pump's alarm shares one ActiveState, Severity and AckedState node and binding them would light every beacon at once. The per-pump supervision states are the alarm indication instead, and they are what drives the condition through ActivatesAlarm in the first place. The plant aggregation is not declared dynamic. The configured pump set is fixed by --pumps at start-up, the ProductionLine already demonstrates dynamic add/remove, and declaring it dynamic made the connector's stale-prim sweep deactivate the Impeller and Bearing component prims that now live under /Plant/Pumps. Verified against a running server: with --pumps 4 the connector composes four referenced pumps at bays 0/2.4/4.8/7.2 m, each with its own shaft angle, fan, gauge needles, casing colour, fluid level and alarm halos. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba --- docs/OpenUsd.md | 2 +- .../Assets/Plant.usda | 282 ++++++++++- .../Assets/generate_pump_assets.py | 36 +- .../Assets/pump.usda | 378 +++++++++++++- .../Assets/remote-pump.usda | 351 ++++++++++++- .../OpenUsdComposition.cs | 102 +++- .../OpenUsdRepresentation.cs | 464 +++++++++++++++--- .../PumpNodeManager.Configure.cs | 55 ++- .../PumpNodeManager.cs | 17 +- samples/PumpDeviceIntegrationServer/README.md | 91 +++- .../PumpInstanceNodeIdRegressionTests.cs | 143 +++++- tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs | 111 ++++- 12 files changed, 1866 insertions(+), 166 deletions(-) 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..df022e14b8 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda @@ -185,7 +185,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 and remote-pump.usda from + # it. Edit it, re-run the generator, never hand-edit the generated + # layers. def Xform "P101" ( + active = false kind = "component" ) { @@ -370,9 +378,40 @@ 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" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.090 + color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] + rel material:binding = + 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 = + double3 xformOp:translate = (0, 0, 0.5) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + 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" ( @@ -941,6 +1111,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"] ) @@ -1110,9 +1354,43 @@ def Xform "Plant" double height = 0.050 double radius = 0.050 color3f[] primvars:displayColor = [(0.20, 0.21, 0.23)] - rel material:binding = + rel material:binding = double3 xformOp:translate = (0, 0, 0.545) uniform token[] xformOpOrder = ["xformOp:translate"] + + # Own material: the limit alarm's severity drives this + # emissive colour and its acknowledged state the opacity, so + # a tripped-but-unacknowledged pump reads differently from one + # an operator has already seen. + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.20, 0.21, 0.23) + color3f inputs:emissiveColor = (0, 0, 0) + float inputs:opacity = 1 + float inputs:roughness = 0.3 + token outputs:surface + } + } + } + + # Overheat halo - gated by the motor-overheat supervision state, so + # an overheating pump is distinguishable from a cavitating one at a + # glance and from across the plant. + def Sphere "OverheatHalo" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.105 + color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] + rel material:binding = + token visibility = "invisible" + double3 xformOp:translate = (0, 0, 0.585) + uniform token[] xformOpOrder = ["xformOp:translate"] } # The lamp itself is permanently mounted; its emissive colour is diff --git a/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py index c9a9c0db93..3ec157ddf9 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py +++ b/samples/PumpDeviceIntegrationServer/Assets/generate_pump_assets.py @@ -15,19 +15,24 @@ 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 signalling +# equipment. Every configured pump is now composed from this asset and driven by +# its own bindings, so each machine carries its own beacon, status light and +# suction vessel rather than sharing one plant-side set. WANTED = [ "Baseplate", "Body", "Casing", "Suction", + "SuctionVessel", "Discharge", "PowerEnd", "Bearing", "Impeller", "CouplingGuard", "Motor", + "Beacon", + "StatusLight", ] # KSB signal blue (RAL 5005) -> OEM moss green (RAL 6011), so the pump federated @@ -105,20 +110,23 @@ def Xform "Pump" ( 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). + composition demo. The server composes one referenced prim per + configured pump (--pumps N), and a ProductionLine aggregates further pumps + the same way (spec 5.12, CompositionArc = Reference/Instance). 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.""" + 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 signalling and instrumentation - beacon, status + light, pressure and bearing-temperature gauges, suction vessel, cooling fan - + because each composed pump is driven by its own bindings and has to show its + own state.""" REMOTE_DOC = """OEM pump asset served by a *different* OPC UA server, referenced across the federation boundary by the cross-server binding diff --git a/samples/PumpDeviceIntegrationServer/Assets/pump.usda b/samples/PumpDeviceIntegrationServer/Assets/pump.usda index d39fb9c344..d8d185838b 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/pump.usda @@ -1,20 +1,23 @@ #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). + composition demo. The server composes one referenced prim per + configured pump (--pumps N), and a ProductionLine aggregates further pumps + the same way (spec 5.12, CompositionArc = Reference/Instance). 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.""" + 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 signalling and instrumentation - beacon, status + light, pressure and bearing-temperature gauges, suction vessel, cooling fan - + because each composed pump is driven by its own bindings and has to show its + own state.""" defaultPrim = "Pump" metersPerUnit = 1 upAxis = "Z" @@ -275,9 +278,40 @@ 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" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.090 + color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] + rel material:binding = + token visibility = "invisible" + double3 xformOp:translate = (-0.845, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] } def Cylinder "BoltTop" ( @@ -333,6 +367,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 = + double3 xformOp:translate = (0, 0, 0.5) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + 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 +535,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 +630,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" ( @@ -846,6 +1008,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 +1222,120 @@ def Xform "Pump" ( uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] } } + + 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"] + + # Own material: the limit alarm's severity drives this + # emissive colour and its acknowledged state the opacity, so + # a tripped-but-unacknowledged pump reads differently from one + # an operator has already seen. + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.20, 0.21, 0.23) + color3f inputs:emissiveColor = (0, 0, 0) + float inputs:opacity = 1 + float inputs:roughness = 0.3 + token outputs:surface + } + } + } + + # Overheat halo - gated by the motor-overheat supervision state, so + # an overheating pump is distinguishable from a cavitating one at a + # glance and from across the plant. + def Sphere "OverheatHalo" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.105 + color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] + rel material:binding = + token visibility = "invisible" + double3 xformOp:translate = (0, 0, 0.585) + 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" + { + 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 + } + } + } } diff --git a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda index cf57beaa41..c48e052c49 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda @@ -268,9 +268,40 @@ def Xform "Pump" ( double height = 0.055 double radius = 0.051 color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - 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.22, 0.33, 0.18) + 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" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.090 + color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] + rel material:binding = + token visibility = "invisible" + double3 xformOp:translate = (-0.845, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] } def Cylinder "BoltTop" ( @@ -326,6 +357,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 = + double3 xformOp:translate = (0, 0, 0.5) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def Cylinder "DownPipe" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform token axis = "X" + double height = 0.42 + double radius = 0.040 + color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] + rel material:binding = + double3 xformOp:translate = (0.28, 0, 0.28) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + } + def Xform "Discharge" { def Cylinder "Neck" ( @@ -433,6 +525,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"] + } + } } } @@ -501,6 +620,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" ( @@ -839,6 +998,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"] ) @@ -979,4 +1212,120 @@ def Xform "Pump" ( uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] } } + + 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"] + + # Own material: the limit alarm's severity drives this + # emissive colour and its acknowledged state the opacity, so + # a tripped-but-unacknowledged pump reads differently from one + # an operator has already seen. + def Material "Mat" + { + token outputs:surface.connect = + + def Shader "Surface" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.20, 0.21, 0.23) + color3f inputs:emissiveColor = (0, 0, 0) + float inputs:opacity = 1 + float inputs:roughness = 0.3 + token outputs:surface + } + } + } + + # Overheat halo - gated by the motor-overheat supervision state, so + # an overheating pump is distinguishable from a cavitating one at a + # glance and from across the plant. + def Sphere "OverheatHalo" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + double radius = 0.105 + color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] + rel material:binding = + token visibility = "invisible" + double3 xformOp:translate = (0, 0, 0.585) + 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" + { + 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 + } + } + } } diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs index 02564eb07b..e85989754b 100644 --- a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs +++ b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs @@ -56,21 +56,104 @@ 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); + } + + /// + /// 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 + cross-server: a ProductionLine aggregating pumps. @@ -288,5 +371,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..a332591e68 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 @@ -70,28 +138,59 @@ public partial class PumpNodeManager /// private async ValueTask RegisterOpenUsdSignalsAsync(CancellationToken cancellationToken) { - foreach (BaseDataVariableState? signal in new[] - { - m_alarmActiveVar, m_shaftAngleVar, m_speedSetpointVar - }) + foreach (PumpTwin twin in m_twins.Values) { - if (signal != null) + foreach (BaseDataVariableState? signal in new[] { - await AddPredefinedNodeAsync(SystemContext, signal, cancellationToken) - .ConfigureAwait(false); + twin.AlarmActive, twin.ShaftAngle, twin.SpeedSetpoint, + twin.BayPosition, twin.FluidSurface + }) + { + if (signal != null) + { + await AddPredefinedNodeAsync(SystemContext, signal, cancellationToken) + .ConfigureAwait(false); + } + } + // Push the shaft angle from the same loop that drives the generated + // measurement Variables, so it uses the one path proven to raise + // data-change notifications. The closure captures this pump's twin, + // so every pump reports its own angle. + if (twin.ShaftAngle != null) + { + PumpTwin captured = twin; + TrackSignal(captured.ShaftAngle!, () => captured.ShaftAngleDegrees); } - } - // 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) - { - TrackSignal(m_shaftAngleVar, () => 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) @@ -227,13 +326,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 +346,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 +399,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 +416,101 @@ 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); + 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)); // DifferentialPressure is published in Pascal; the EmissiveColor - // render target brightens over 0..6 bar, so scale Pa -> bar. + // render target brightens over 0..6 bar, so the binding scales + // Pascal to bar. The datasheet tops out at 4 bar, well inside it. 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); + GuidFor("DiffPressureEmissive"), + diffPressure, primPath + "/StatusLight/Mat/Surface", "inputs:emissiveColor", "color3f", + OpenUsdRenderTargetKindEnum.EmissiveColor, PascalToBar); + + // 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, "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); + 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 +522,163 @@ 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 lights the plant beacon halo. + CreateBinding(rep, ns, "AlarmActiveVisibility", + GuidFor("AlarmActiveVisibility"), + twin.AlarmActive!.NodeId, primPath + "/StatusLight", "visibility", "token", + OpenUsdRenderTargetKindEnum.Visibility, 1.0, + bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, + alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); + + // Distinct indications: the operator sees a cavitating pump and an + // overheating pump differently, at the place on the machine where the + // fault actually is. + 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 + "/Beacon/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 + // light every beacon in the plant 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"), + ["DiffPressureEmissive"] = new Guid("c2b2a7e1-6d3c-5b2f-a04b-3c8d5e9f1122"), + ["AlarmActiveVisibility"] = 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; + + /// + /// Pascal to bar. The EmissiveColor render target brightens over 0..6 bar. + /// + private const double PascalToBar = 1e-5; + + /// + /// 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 +743,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/PumpNodeManager.Configure.cs b/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs index b1d834836e..84fee2f9aa 100644 --- a/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs +++ b/samples/PumpDeviceIntegrationServer/PumpNodeManager.Configure.cs @@ -52,18 +52,6 @@ namespace Pumps /// public partial class PumpNodeManager { - /// - /// Shaft angular position of the primary pump, in degrees. This is what - /// makes the OpenUSD twin look like it is running. - /// - private double ShaftAngleDegrees => - m_primarySimulation?.ShaftAngleDegrees ?? 0.0; - - /// - /// The supervision alarm state the OpenUSD status-light binding follows. - /// - private bool AlarmActive => m_primarySimulation?.AlarmActive ?? false; - partial void Configure(INodeManagerBuilder builder) { Server.Telemetry.CreateLogger() @@ -134,8 +122,11 @@ private void RegisterPumpSimulation( pump.NodeId); } - // The OpenUSD twin follows the first pump, so remember it. - m_primarySimulation ??= simulation; + // The OpenUSD twin of this pump renders this simulation. + if (m_twins.TryGetValue(pump.NodeId, out PumpTwin? twin)) + { + twin.Simulation = simulation; + } } } @@ -427,12 +418,25 @@ private void PublishOpenUsdSignals() variable.ClearChangeMasks(SystemContext, includeChildren: false); } - BaseDataVariableState? alarm = m_alarmActiveVar; - if (alarm != null) + foreach (PumpTwin twin in m_twins.Values) { - alarm.Value = AlarmActive; - alarm.Timestamp = now; - alarm.ClearChangeMasks(SystemContext, includeChildren: false); + BaseDataVariableState? alarm = twin.AlarmActive; + if (alarm != null) + { + alarm.Value = twin.AlarmActiveState; + alarm.Timestamp = now; + alarm.ClearChangeMasks(SystemContext, includeChildren: false); + } + + BaseDataVariableState? surface = twin.FluidSurface; + if (surface != null) + { + surface.Value = new ExtensionObject( + FluidSurfaceAt(twin.Simulation?.LevelMetres ?? + PumpDatasheet.Simulation.LevelNominal)); + surface.Timestamp = now; + surface.ClearChangeMasks(SystemContext, includeChildren: false); + } } } @@ -531,6 +535,7 @@ private void Publish(long tick, bool publishAll, StatusCode statusCode) double level = PumpDatasheet.Simulation.LevelNominal + (PumpDatasheet.Simulation.LevelAmplitude * Math.Sin(localTick * PumpDatasheet.Simulation.LevelRate)); + Volatile.Write(ref m_levelMetres, level); m_pressure.SetValue(differentialPressure, statusCode, sourceTimestamp); m_flow.SetValue(massFlow, statusCode, sourceTimestamp); @@ -651,6 +656,12 @@ private static double CoolingFaultExcursion(long tick) /// public bool AlarmActive => m_currentCavitation || m_currentMotorOverheat; + /// + /// Suction vessel level in metres, as last published. Drives the + /// OpenUSD liquid-surface binding. + /// + public double LevelMetres => Volatile.Read(ref m_levelMetres); + private readonly long m_phaseOffset; private readonly TimeSpan m_tickInterval; private readonly IValueUpdater m_pressure; @@ -667,6 +678,7 @@ private static double CoolingFaultExcursion(long tick) private bool m_currentCavitation; private bool m_currentMotorOverheat; private double m_shaftAngle; + private double m_levelMetres; private bool m_hasPublishedGoodValue; } @@ -680,11 +692,6 @@ private static double CoolingFaultExcursion(long tick) /// with the getter that yields their latest simulated value. /// private readonly List<(BaseVariableState Variable, Func Getter)> m_liveSignals = []; - - /// - /// The simulation the OpenUSD twin follows. - /// - private PumpSimulationState? m_primarySimulation; } internal static partial class PumpNodeManagerLog diff --git a/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs b/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs index a9d16acb3e..4928e31b35 100644 --- a/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs +++ b/samples/PumpDeviceIntegrationServer/PumpNodeManager.cs @@ -259,11 +259,13 @@ await MaterialiseOpenUsdFacilityAsync(cancellationToken) firstPump ??= pump; } - // The OpenUSD twin follows the first pump. - if (firstPump != null) - { - OrganiseRepresentation(firstPump); - } + // Every pump is a twin in its own right, so every representation has to + // be discoverable — a connector finds them through this registry alone. + OrganiseRepresentations(); + + // Plant-level aggregation: composes one full-fidelity pump prim per + // configured pump, so the rendered scene scales with --pumps N. + await MaterialisePlantAggregationAsync(cancellationToken).ConfigureAwait(false); // Composition demo: a ProductionLine aggregating 1..n pumps (Many), with a // dynamically added/removed pump (model-change events) and a cross-server @@ -323,7 +325,8 @@ private async ValueTask MaterialisePumpInstanceAsync( // mandatory children from the parent chain. PumpState pump = SystemContext .CreateInstanceOfPumpType(deviceSet, pumpBrowseName); - pump.DisplayName = new LocalizedText(GetPumpDisplayName(m_pumpStates.Count + 1)); + int pumpNumber = m_pumpStates.Count + 1; + pump.DisplayName = new LocalizedText(GetPumpDisplayName(pumpNumber)); pump.ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.Organizes; deviceSet.AddChild(pump); @@ -334,7 +337,7 @@ private async ValueTask MaterialisePumpInstanceAsync( // registration. Per-instance NodeIds are already assigned by the // generated CreateOrReplace/AddXxx helpers, so the binding source // NodeIds captured here are the instance ones. - AttachOpenUsdRepresentation(pump); + AttachOpenUsdRepresentation(pump, pumpNumber); await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken) .ConfigureAwait(false); diff --git a/samples/PumpDeviceIntegrationServer/README.md b/samples/PumpDeviceIntegrationServer/README.md index 9a718b9475..f393416900 100644 --- a/samples/PumpDeviceIntegrationServer/README.md +++ b/samples/PumpDeviceIntegrationServer/README.md @@ -223,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 @@ -238,12 +255,17 @@ 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 and +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. Both are generated from the `/Plant/Pumps/P101` master by +`Assets/generate_pump_assets.py` — edit the master and re-run the +generator, never the generated layers. 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 @@ -253,35 +275,66 @@ turn. ### 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) | +| `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` | `…/StatusLight/Mat/Surface.inputs:emissiveColor` | lamp glow tracks discharge pressure | -| supervision alarm | `…/P101/StatusLight.visibility` | shows the alarm halo | +| `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 | `…/StatusLight.visibility` | beacon alarm halo | +| `Cavitation` | `…/Suction/CavitationHalo.visibility` | cavitation, at the suction eye where the fault is | +| `MotorOverheat` | `…/Beacon/OverheatHalo.visibility` | overheat, distinct from cavitation | +| `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. +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 beacon mast, housing and lamp are permanently mounted; only the alarm -halo is gated by `visibility`, so a cleared alarm still leaves a lamp +halos are gated by `visibility`, so a cleared alarm still leaves a lamp whose glow tracks discharge pressure. +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 light every beacon in the plant 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. 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 @@ -363,6 +416,7 @@ workflow on every push to `master` and on manual dispatch. | 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 @@ -374,11 +428,22 @@ 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 +│ ├── remote-pump.usda # generated OEM-livery variant +│ └── generate_pump_assets.py # regenerates both from the P101 master ├── Model/ │ ├── Opc.Ua.Machinery.NodeSet2.xml # AdditionalFiles — build-time only │ └── Opc.Ua.Pumps.NodeSet2.xml # AdditionalFiles — build-time only diff --git a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs index 238f9d08ac..e7081a9c36 100644 --- a/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs +++ b/tests/Opc.Ua.Di.Tests/PumpInstanceNodeIdRegressionTests.cs @@ -67,14 +67,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 +82,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,6 +126,7 @@ public sealed class PumpInstanceNodeIdRegressionTests "Events/SupervisionPumpOperation/MotorOverheat", "Events/SupervisionPumpOperation/MotorOverheat/FalseState", "Events/SupervisionPumpOperation/MotorOverheat/TrueState", + "FluidSurfacePosition", "Identification", "Identification/ArticleNumber", "Identification/AssetId", @@ -172,6 +170,18 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/AlarmActiveVisibility/TargetPropertyName", "OpenUsdRepresentation/AlarmActiveVisibility/TargetStage", "OpenUsdRepresentation/AlarmActiveVisibility/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", @@ -192,6 +202,32 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/BearingTempColor/TargetPropertyName", "OpenUsdRepresentation/BearingTempColor/TargetStage", "OpenUsdRepresentation/BearingTempColor/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/DiffPressureEmissive", "OpenUsdRepresentation/DiffPressureEmissive/BadQualityAction", "OpenUsdRepresentation/DiffPressureEmissive/BindingDefinitionId", @@ -204,6 +240,43 @@ public sealed class PumpInstanceNodeIdRegressionTests "OpenUsdRepresentation/DiffPressureEmissive/TargetPropertyName", "OpenUsdRepresentation/DiffPressureEmissive/TargetStage", "OpenUsdRepresentation/DiffPressureEmissive/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", @@ -211,6 +284,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", @@ -238,6 +361,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", diff --git a/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs b/tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs index a4daa0a582..7614a4443b 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 (11) + supervision alarms (3) + command (1). + Assert.That(rep.Bindings, Has.Count.EqualTo(16)); } [Test] @@ -429,7 +436,7 @@ public async Task AlarmBindingDrivesUsdVisibilityAsync() // 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, + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/StatusLight", "visibility"), Is.True, "Alarm binding did not author StatusLight visibility."); } @@ -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,11 +564,11 @@ 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, + Assert.That(sink.WasWritten("/Plant/Pumps/Pump_1/StatusLight/Mat/Surface", "inputs:emissiveColor"), Is.True, "EmissiveColor binding 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."); }); } @@ -666,7 +683,7 @@ public async Task ComponentBindingsAreDiscoverableAsync() System.Collections.Generic.List reps = await connector.DiscoverAllRepresentationsAsync(CancellationToken.None).ConfigureAwait(false); - OpenUsdConnector.RepresentationInfo? pump = reps.Find(r => r.PrimPath == "/Plant/Pumps/P101"); + OpenUsdConnector.RepresentationInfo? pump = reps.Find(r => r.PrimPath == "/Plant/Pumps/Pump_1"); OpenUsdConnector.RepresentationInfo? line = reps.Find(r => r.PrimPath == "/Plant/Line1"); Assert.Multiple(() => @@ -681,11 +698,73 @@ public async Task ComponentBindingsAreDiscoverableAsync() }); } + /// + /// 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(() => + { + 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."); + } + 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) From 8fc836903015edcdd6632d9eb2e78bcf703d9749 Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 13:40:29 +0200 Subject: [PATCH 4/8] Give the pump hall an operator viewpoint and stop phantom alarms Three things made the rendered hall misread. The aggregation demo shared the main aisle. Its line pumps sit on 1.2 m centres while the configured pumps march along the same axis on 2.4 m centres, so the two rows interleaved and the hall showed more machines than a client could browse - the reported "5 pumps when only 4 started". Line1 now stands in its own aisle behind the suction vessels, which is also what it is: a composition demo, not part of the configured line. Every machine wore a permanent alarm halo. StatusLight was authored visibility = "inherited", so a pump nothing is bound to - an aggregated line pump, or the one federated from another server - stood there showing an alarm it never raised. It now defaults to invisible and only a live supervision binding reveals it, which is what makes the halo mean something. The camera was a gantry shot down the line, framed for a single pump at the origin. It is now an operator's viewpoint: eye height 1.65 m in the aisle on the discharge side, pitched 7 degrees below horizontal, framing every configured pump at a three-quarter angle with the vessels behind them and the back aisle further back again. The framing was solved against the projection rather than guessed, and holds from one pump up to eight, so it does not need retuning for --pumps N. The floor is widened to match; the old one ended at y = 4.2 and left the far pumps hanging over black. Verified in the viewport with --pumps 5: five machines in shot, each turning at its own speed, no alarm showing unless its own supervision state asserts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba --- .../Assets/Plant.usda | 39 +++++++++++++------ .../Assets/pump.usda | 6 ++- .../Assets/remote-pump.usda | 6 ++- samples/PumpDeviceIntegrationServer/README.md | 25 ++++++++++++ 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda index df022e14b8..cd80fdd5ed 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda @@ -151,20 +151,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 +178,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) + (-11, -8, 0), (11, -8, 0), (11, 20, 0), (-11, 20, 0) ] normal3f[] primvars:normals = [ (0, 0, 1), (0, 0, 1), (0, 0, 1), (0, 0, 1) @@ -1414,7 +1419,11 @@ def Xform "Plant" double3 xformOp:translate = (0.10, -0.32, 0.12) uniform token[] xformOpOrder = ["xformOp:translate"] - token visibility = "inherited" + # Hidden until the supervision binding asserts. The default has to + # be invisible: a pump nothing is bound to - an aggregated line + # pump, or one federated from another server - would otherwise + # stand there showing a permanent alarm it never raised. + token visibility = "invisible" # Alarm halo - only this is gated by the supervision alarm, so a # cleared alarm leaves the beacon hardware and lamp on show. @@ -1448,6 +1457,14 @@ def Xform "Plant" def Xform "Line1" { + # The aggregation demo stands in its own aisle behind the suction + # vessels. The configured pumps march along +Y on the main aisle at + # 2.4 m centres, so a line row sharing that aisle would interleave with + # them and the hall would read as one jumbled row of machines that do + # not correspond to the pumps a client can browse. + double3 xformOp:translate = (-7.0, 0, 0) + uniform token[] xformOpOrder = ["xformOp:translate"] + def Scope "Pumps" { def Xform "P_201" diff --git a/samples/PumpDeviceIntegrationServer/Assets/pump.usda b/samples/PumpDeviceIntegrationServer/Assets/pump.usda index d8d185838b..d8ad1a6666 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/pump.usda @@ -1309,7 +1309,11 @@ def Xform "Pump" ( double3 xformOp:translate = (0.10, -0.32, 0.12) uniform token[] xformOpOrder = ["xformOp:translate"] - token visibility = "inherited" + # Hidden until the supervision binding asserts. The default has to + # be invisible: a pump nothing is bound to - an aggregated line + # pump, or one federated from another server - would otherwise + # stand there showing a permanent alarm it never raised. + token visibility = "invisible" # Alarm halo - only this is gated by the supervision alarm, so a # cleared alarm leaves the beacon hardware and lamp on show. diff --git a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda index c48e052c49..50eacbb553 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda @@ -1299,7 +1299,11 @@ def Xform "Pump" ( double3 xformOp:translate = (0.10, -0.32, 0.12) uniform token[] xformOpOrder = ["xformOp:translate"] - token visibility = "inherited" + # Hidden until the supervision binding asserts. The default has to + # be invisible: a pump nothing is bound to - an aggregated line + # pump, or one federated from another server - would otherwise + # stand there showing a permanent alarm it never raised. + token visibility = "invisible" # Alarm halo - only this is gated by the supervision alarm, so a # cleared alarm leaves the beacon hardware and lamp on show. diff --git a/samples/PumpDeviceIntegrationServer/README.md b/samples/PumpDeviceIntegrationServer/README.md index f393416900..4530757d0a 100644 --- a/samples/PumpDeviceIntegrationServer/README.md +++ b/samples/PumpDeviceIntegrationServer/README.md @@ -273,6 +273,31 @@ 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 + +| Aisle | Contents | +| --- | --- | +| Main aisle, `x ≈ 0` | the configured pumps, one bay every 2.4 m along +Y | +| Behind them, `x = -1.35` | each pump's suction vessel | +| Back aisle, `x = -7` | the `ProductionLine` aggregation demo and the cross-server pump | + +The aggregation demo has its own aisle on purpose. Its pumps sit on 1.2 m +centres, so sharing the main aisle would interleave them with the +configured pumps and the hall would read as one jumbled row of machines +that do not correspond to anything a client can browse. + +`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 and the back +aisle further back again. 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 unbound machine never shows an alarm: `StatusLight` is authored +`invisible` and only a live supervision binding reveals it. Without that +default an aggregated line pump — which nothing is bound to — would stand +there showing a permanent alarm it never raised. + ### Live bindings All paths are relative to the pump's own prim, so every pump drives its From 0521e5f74a297bea5ffa3cfc7122adc62c967ebc Mon Sep 17 00:00:00 2001 From: Marc Date: Sat, 1 Aug 2026 14:25:40 +0200 Subject: [PATCH 5/8] Show only the simulated pumps and ring them in red on error The hall contained geometry that no longer belonged there. A demo production line authored three empty pump bodies plus a "remote" pump representing a machine on another server, none of which the connected server simulates, so the viewport showed more pumps than the server had and the extra ones never moved. Remove them. `/Plant/Line1` and `remote-pump.usda` are gone, the production line now aggregates plain topology objects without a representation, and the stage serves two assets instead of three. What renders is exactly the set of pumps the server simulates. Replace the alarm light bulb with an alarm ring. A red annulus is authored around each pump and made visible while the pump is in an error condition, so the fault reads at a glance from any angle instead of depending on a small emissive lamp. The bearing overheat halo moves onto the bearing bracket where the heat actually is, and the differential pressure lamp binding retires with the lamp. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba --- .../Assets/Plant.usda | 205 +-- .../Assets/generate_pump_assets.py | 67 +- .../Assets/pump.usda | 168 +-- .../Assets/remote-pump.usda | 1335 ----------------- .../OpenUsdComposition.cs | 67 +- .../OpenUsdRepresentation.cs | 39 +- .../PumpDeviceIntegrationServer.csproj | 3 +- samples/PumpDeviceIntegrationServer/README.md | 61 +- .../PumpInstanceNodeIdRegressionTests.cs | 38 +- tests/Opc.Ua.Di.Tests/PumpOpenUsdE2eTests.cs | 111 +- 10 files changed, 212 insertions(+), 1882 deletions(-) delete mode 100644 samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda diff --git a/samples/PumpDeviceIntegrationServer/Assets/Plant.usda b/samples/PumpDeviceIntegrationServer/Assets/Plant.usda index cd80fdd5ed..417f1bcc8a 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 @@ -178,7 +176,7 @@ def Xform "Plant" int[] faceVertexCounts = [4] int[] faceVertexIndices = [0, 1, 2, 3] point3f[] points = [ - (-11, -8, 0), (11, -8, 0), (11, 20, 0), (-11, 20, 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) @@ -407,13 +405,10 @@ def Xform "Plant" # Cavitation indication, at the suction eye where the fault is. # Hidden until the supervision state asserts. - def Sphere "CavitationHalo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) + def Sphere "CavitationHalo" { double radius = 0.090 color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] - rel material:binding = token visibility = "invisible" double3 xformOp:translate = (-0.845, 0, 0.28) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -793,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) @@ -1331,165 +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"] - - # Own material: the limit alarm's severity drives this - # emissive colour and its acknowledged state the opacity, so - # a tripped-but-unacknowledged pump reads differently from one - # an operator has already seen. - def Material "Mat" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.20, 0.21, 0.23) - color3f inputs:emissiveColor = (0, 0, 0) - float inputs:opacity = 1 - float inputs:roughness = 0.3 - token outputs:surface - } - } - } - - # Overheat halo - gated by the motor-overheat supervision state, so - # an overheating pump is distinguishable from a cavitating one at a - # glance and from across the plant. - def Sphere "OverheatHalo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.105 - color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] - rel material:binding = - token visibility = "invisible" - double3 xformOp:translate = (0, 0, 0.585) - 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"] - - # Hidden until the supervision binding asserts. The default has to - # be invisible: a pump nothing is bound to - an aggregated line - # pump, or one federated from another server - would otherwise - # stand there showing a permanent alarm it never raised. + 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" - - # 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 - } - } } } } - def Xform "Line1" - { - # The aggregation demo stands in its own aisle behind the suction - # vessels. The configured pumps march along +Y on the main aisle at - # 2.4 m centres, so a line row sharing that aisle would interleave with - # them and the hall would read as one jumbled row of machines that do - # not correspond to the pumps a client can browse. - double3 xformOp:translate = (-7.0, 0, 0) - uniform token[] xformOpOrder = ["xformOp:translate"] - - 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 3ec157ddf9..5dd150a793 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,10 +15,10 @@ HERE = pathlib.Path(__file__).parent PLANT = HERE / "Plant.usda" -# The P101 children that make up the machine itself, including the signalling -# equipment. Every configured pump is now composed from this asset and driven by -# its own bindings, so each machine carries its own beacon, status light and -# suction vessel rather than sharing one plant-side set. +# 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", @@ -28,22 +28,13 @@ "Discharge", "PowerEnd", "Bearing", + "OverheatHalo", "Impeller", "CouplingGuard", "Motor", - "Beacon", - "StatusLight", + "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 @@ -85,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}""" @@ -110,11 +98,9 @@ def Xform "Pump" ( PUMP_DOC = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings - composition demo. The server composes one referenced prim per - configured pump (--pumps N), and a ProductionLine aggregates further pumps - the same way (spec 5.12, CompositionArc = Reference/Instance). 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 composed pump renders at the same fidelity: an EN 733 @@ -123,28 +109,17 @@ def Xform "Pump" ( centreline 0.160 m above the baseplate. Do not edit by hand - edit P101 and re-run the generator. - The asset carries its own signalling and instrumentation - beacon, status - light, pressure and bearing-temperature gauges, suction vessel, cooling fan - - because each composed pump is driven by its own bindings and has to show its - own state.""" - -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. - - 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.""" + 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.""" 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 d8ad1a6666..3a69756d16 100644 --- a/samples/PumpDeviceIntegrationServer/Assets/pump.usda +++ b/samples/PumpDeviceIntegrationServer/Assets/pump.usda @@ -1,11 +1,9 @@ #usda 1.0 ( doc = """Reusable single-pump USD asset for the OPC UA - OpenUSD Bindings - composition demo. The server composes one referenced prim per - configured pump (--pumps N), and a ProductionLine aggregates further pumps - the same way (spec 5.12, CompositionArc = Reference/Instance). 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 composed pump renders at the same fidelity: an EN 733 @@ -14,10 +12,10 @@ centreline 0.160 m above the baseplate. Do not edit by hand - edit P101 and re-run the generator. - The asset carries its own signalling and instrumentation - beacon, status - light, pressure and bearing-temperature gauges, suction vessel, cooling fan - - because each composed pump is driven by its own bindings and has to show its - own state.""" + 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.""" defaultPrim = "Pump" metersPerUnit = 1 upAxis = "Z" @@ -302,13 +300,10 @@ def Xform "Pump" ( # Cavitation indication, at the suction eye where the fault is. # Hidden until the supervision state asserts. - def Sphere "CavitationHalo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) + def Sphere "CavitationHalo" { double radius = 0.090 color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] - rel material:binding = token visibility = "invisible" double3 xformOp:translate = (-0.845, 0, 0.28) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -685,6 +680,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) @@ -1223,123 +1227,29 @@ def Xform "Pump" ( } } - def Xform "Beacon" + def Mesh "AlarmRing" { - 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"] - - # Own material: the limit alarm's severity drives this - # emissive colour and its acknowledged state the opacity, so - # a tripped-but-unacknowledged pump reads differently from one - # an operator has already seen. - def Material "Mat" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.20, 0.21, 0.23) - color3f inputs:emissiveColor = (0, 0, 0) - float inputs:opacity = 1 - float inputs:roughness = 0.3 - token outputs:surface - } - } - } - - # Overheat halo - gated by the motor-overheat supervision state, so - # an overheating pump is distinguishable from a cavitating one at a - # glance and from across the plant. - def Sphere "OverheatHalo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.105 - color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] - rel material:binding = - token visibility = "invisible" - double3 xformOp:translate = (0, 0, 0.585) - 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" - { - double3 xformOp:translate = (0.10, -0.32, 0.12) - uniform token[] xformOpOrder = ["xformOp:translate"] - - # Hidden until the supervision binding asserts. The default has to - # be invisible: a pump nothing is bound to - an aggregated line - # pump, or one federated from another server - would otherwise - # stand there showing a permanent alarm it never raised. + 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" - - # 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 - } - } } } diff --git a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda b/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda deleted file mode 100644 index 50eacbb553..0000000000 --- a/samples/PumpDeviceIntegrationServer/Assets/remote-pump.usda +++ /dev/null @@ -1,1335 +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"] - - # 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.22, 0.33, 0.18) - 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" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.090 - color3f[] primvars:displayColor = [(0.95, 0.85, 0.15)] - rel material:binding = - token visibility = "invisible" - double3 xformOp:translate = (-0.845, 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 "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 = - double3 xformOp:translate = (0, 0, 0.5) - uniform token[] xformOpOrder = ["xformOp:translate"] - } - - def Cylinder "DownPipe" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - uniform token axis = "X" - double height = 0.42 - double radius = 0.040 - color3f[] primvars:displayColor = [(0.22, 0.33, 0.18)] - rel material:binding = - double3 xformOp:translate = (0.28, 0, 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"] - } - - # 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"] - } - } - } - } - - 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"] - } - - # 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" ( - 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"] - } - - # 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"] - ) - { - 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"] - } - } - - 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"] - - # Own material: the limit alarm's severity drives this - # emissive colour and its acknowledged state the opacity, so - # a tripped-but-unacknowledged pump reads differently from one - # an operator has already seen. - def Material "Mat" - { - token outputs:surface.connect = - - def Shader "Surface" - { - uniform token info:id = "UsdPreviewSurface" - color3f inputs:diffuseColor = (0.20, 0.21, 0.23) - color3f inputs:emissiveColor = (0, 0, 0) - float inputs:opacity = 1 - float inputs:roughness = 0.3 - token outputs:surface - } - } - } - - # Overheat halo - gated by the motor-overheat supervision state, so - # an overheating pump is distinguishable from a cavitating one at a - # glance and from across the plant. - def Sphere "OverheatHalo" ( - prepend apiSchemas = ["MaterialBindingAPI"] - ) - { - double radius = 0.105 - color3f[] primvars:displayColor = [(1.0, 0.18, 0.08)] - rel material:binding = - token visibility = "invisible" - double3 xformOp:translate = (0, 0, 0.585) - 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" - { - double3 xformOp:translate = (0.10, -0.32, 0.12) - uniform token[] xformOpOrder = ["xformOp:translate"] - - # Hidden until the supervision binding asserts. The default has to - # be invisible: a pump nothing is bound to - an aggregated line - # pump, or one federated from another server - would otherwise - # stand there showing a permanent alarm it never raised. - token visibility = "invisible" - - # 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 - } - } - } -} diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs index e85989754b..2c88fc09f9 100644 --- a/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs +++ b/samples/PumpDeviceIntegrationServer/OpenUsdComposition.cs @@ -156,7 +156,12 @@ await AddPredefinedNodeAsync(SystemContext, plantRep, cancellationToken) } } - // 1..n + dynamic + cross-server: a ProductionLine aggregating pumps. + // 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) @@ -184,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", @@ -204,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); @@ -250,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 diff --git a/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs index a332591e68..674c91724c 100644 --- a/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs +++ b/samples/PumpDeviceIntegrationServer/OpenUsdRepresentation.cs @@ -271,7 +271,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")), }; } @@ -449,14 +448,6 @@ private void AttachOpenUsdRepresentation(PumpState pump, int pumpNumber) (PumpDatasheet.Ranges.BearingTemperatureMax - PumpDatasheet.Ranges.BearingTemperatureMin)); - // DifferentialPressure is published in Pascal; the EmissiveColor - // render target brightens over 0..6 bar, so the binding scales - // Pascal to bar. The datasheet tops out at 4 bar, well inside it. - CreateBinding(rep, ns, "DiffPressureEmissive", - GuidFor("DiffPressureEmissive"), - diffPressure, primPath + "/StatusLight/Mat/Surface", "inputs:emissiveColor", "color3f", - OpenUsdRenderTargetKindEnum.EmissiveColor, PascalToBar); - // Discharge pressure gauge needle over the datasheet pressure range. CreateBinding(rep, ns, "DischargePressureNeedle", GuidFor("DischargePressureNeedle"), @@ -559,17 +550,19 @@ private void AttachAlarmBindings( NodeId cavitation = events?.SupervisionProcessFluid?.Cavitation?.NodeId ?? NodeId.Null; NodeId motorOverheat = events?.SupervisionPumpOperation?.MotorOverheat?.NodeId ?? NodeId.Null; - // Any active supervision state lights the plant beacon halo. - CreateBinding(rep, ns, "AlarmActiveVisibility", - GuidFor("AlarmActiveVisibility"), - twin.AlarmActive!.NodeId, primPath + "/StatusLight", "visibility", "token", + // 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); - // Distinct indications: the operator sees a cavitating pump and an - // overheating pump differently, at the place on the machine where the - // fault actually is. + // 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", @@ -578,7 +571,7 @@ private void AttachAlarmBindings( alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); CreateBinding(rep, ns, "OverheatHalo", GuidFor("OverheatHalo"), - motorOverheat, primPath + "/Beacon/OverheatHalo", "visibility", "token", + motorOverheat, primPath + "/OverheatHalo", "visibility", "token", OpenUsdRenderTargetKindEnum.Visibility, 1.0, bindingTypeId: Opc.Ua.OpenUsd.ObjectTypes.OpenUsdAlarmBindingType, alarmAspect: OpenUsdAlarmAspectEnum.ActiveState); @@ -588,8 +581,8 @@ private void AttachAlarmBindings( // standard namespace-0 declaration NodeIds - which // PumpInstanceNodeIdRegressionTests pins - so every pump's alarm shares // one ActiveState, Severity and AckedState node. Binding those would - // light every beacon in the plant at once. The per-pump supervision - // states above are the alarm indication instead: they are genuinely per + // 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. } @@ -619,8 +612,7 @@ private static ThreeDCartesianCoordinates FluidSurfaceAt(double levelMetres) { ["ShaftSpin"] = new Guid("6e63cf2c-f2de-4f78-a8f8-f0ccdbb7647a"), ["BearingTempColor"] = new Guid("b1a1f6f0-5c2b-5a1e-9f3a-2b7c4d8e0011"), - ["DiffPressureEmissive"] = new Guid("c2b2a7e1-6d3c-5b2f-a04b-3c8d5e9f1122"), - ["AlarmActiveVisibility"] = new Guid("d3c3b8f2-7e4d-5c30-b15c-4d9e6a0b2233"), + ["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"), @@ -667,11 +659,6 @@ private static Guid GuidFor(string binding) /// private const double KelvinOffset = 273.15; - /// - /// Pascal to bar. The EmissiveColor render target brightens over 0..6 bar. - /// - private const double PascalToBar = 1e-5; - /// /// 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 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