Files
UA-.NETStandard/samples/GeneratorServer/OpenUsdRepresentation.cs
T
Marc SchierandCopilot ca89ac85a7 Add a Generators companion-spec sample and compose servers at a site level (#4180)
# Description

Adds a **Generators companion-specification sample** alongside the
existing pump sample, and a **`SiteCompositionServer`** that composes
several device servers into one OpenUSD scene at a supervisory level —
demonstrating that servers can be aggregated without mirroring their
address spaces.

Three things, in dependency order:

### 1. `GeneratorServer`

A self-contained server realising the draft Generators spec
(`http://opcfoundation.org/UA/Generators/`) end to end: N simulated
generating sets, DI + Machinery integration, a twelve-state operating
state machine, four protection alarms per set, six control methods, and
a per-set OpenUSD twin.

The organising idea is that **load fraction is the only independent
variable**. Everything else is a function of it:

```
V̇_f(x) = 3.67 + 100·x                fuel rate      [L/h]
η(x)   = P(x) / (V̇_f(x) · ρ · LHV)   efficiency
S = P / PF     I = S / (√3·V_LL)     f = N·p / 120
```

This is not stylistic. When each measurement is an independent
oscillator — as the pump sample's simulation once was — a server happily
publishes a duty point no real machine could occupy, and no test can
catch it because there is nothing for the values to be inconsistent
*with*. Deriving them from one variable means `P = √3·V·I·PF` and `η =
P/(V̇·ρ·LHV)` hold at every tick by construction. The published
`DATASHEET.md`, the engineering ranges, the trip points and the
simulation all read the same constants in `GeneratorDatasheet.cs`, so
the document and the server cannot describe different machines.

The machine is a fictitious **SimGen Systems GenX-500** (400 kW prime /
440 kW standby, 400/230 V, 50 Hz, four-pole). Figures are representative
of a real industrial genset class; no vendor is named anywhere in the
code or docs.

### 2. Cross-server composition in the connector

`RemoteSessionFactory` already existed in the client library but the
connector CLI never set it, so cross-server composition was unreachable.
`--federate` wires it up: the connector opens a session to each server
named by a component binding, discovers its representations and drives
its bindings into the same stage.

It is **opt-in** because the endpoint the connector dials comes from the
server being rendered rather than from the operator, which makes
honouring it a trust decision. Federation is best-effort per component —
a subordinate that is down is logged and skipped, and the rest of the
scene still renders — which is the only `src/` change in this PR.

### 3. `SiteCompositionServer`

Owns no devices. It publishes a site stage plus one cross-server
component binding per subordinate, carrying that server's
`ComponentServerUri` and `ComponentEndpointUrl`. Nothing is mirrored, so
there is no cache to invalidate and no second copy of the truth.

## Notes for reviewers

**Everything interesting in this PR was found by running the servers,
not by reading the code.** Six defects, none of which looked wrong in
review:

- `IsShutdown` and `SubsystemName` are *optional* on
`GeneratorProtectionAlarmType`, so the generated factory does not
materialise them. `CreateOrReplace` alone produces a child that exists,
appears in `GetChildren` and holds the right value — but carries **no
`ReferenceTypeId`**, so no browse can reach it. Every alarm was
publishing its trip without saying whether the trip stops the machine or
which subsystem to go to. `AddXxx(context)` first is what gives the
child its `HasProperty` reference.
- **The protections could never fire.** Every trip point sits outside
the band the datasheet curves produce — that is what a datasheet *means*
— so all four alarms, the shutdown class, `ResetFaults` and the whole
`Fault` branch of the state machine were unreachable while the README
documented them as observable. Overload was worse: the load clamp
ceiling was set to exactly the trip point, making a
strictly-greater-than comparison unsatisfiable. Faults are now
injectable, and the first set — the one the hero camera frames —
develops one on a slow rotation.
- **A shutdown trip removes the condition that caused it**, because oil
pressure and coolant temperature are only supervised while the engine
turns. Each alarm went active for one tick and cleared, leaving an
operator with a stopped machine and no indication of why. Shutdown-class
alarms now latch until the set leaves the shutdown state. Every unit
test that checked the *condition* passed throughout — the defect only
appears when the trip and the supervision interact over time.
- **`Synchronizing` and `Paralleled` were declared, drawn in the README
and never entered.** The first set now energises a dead bus and closes
onto it while every other set synchronises to a live one — both how a
real plant parallels, and what makes the two states observable.
- **The tick raced client method calls.** The simulation tick runs on a
thread-pool thread while method calls arrive on request threads, and
both transition sets and write the same nodes. A tick moving a set to
`Cooldown` and a concurrent `EmergencyStop` could interleave the paired
`CurrentState` / `CurrentState.Id` writes and leave a client with a
state *name* from one transition and a state *node* from the other — the
exact failure the paired write exists to prevent, reintroduced by the
threading model. Both paths now take one gate.
- Low oil pressure supervised from raw speed **tripped every set during
cranking**, because pressure has not built yet. Now gated on
`IsSpinning`, which is what a real start-up bypass does.

Smaller fixes: shutdown trips are applied after the whole evaluation
pass (stopping mid-loop made the remaining conditions read healthy,
collapsing simultaneous trips to whichever came first in the table);
`ResetFaults` reports its clears as events (a client learns of condition
state changes only through events, so a silent clear leaves an
alarm-list client showing it forever); the start counter moved into the
transition so commanded starts are counted; the federated connector
closes the remote session if the connector constructor throws between
taking ownership and registering it; and `prepare_machinery_nodeset.py`
now *asserts* the IA namespace is last before removing it rather than
only claiming so in its docstring — removing any earlier entry would
silently renumber every namespace after it and rebind their NodeIds.

**A model limitation is left visible rather than papered over.**
`GeneratorStateMachineType` declares an emergency stop only out of
`Running`, `Loaded` and `Paralleled`, so the sample refuses
`EmergencyStop` from `Starting` and `Warmup`. A real panel stops from
anywhere. That is the specification's shape, not this sample's choice,
so it is pinned by a test and named in the docs.

**`Model/` vendors a reduced Machinery nodeset**, derived mechanically
by whitelist. The full official nodeset does not survive the model
source generator (`MODELGEN003`) and drags in IA through a single
optional `Stacklight` member a generating set does not have. Deriving it
by script keeps the provenance checkable; the whitelist is the only
thing to edit when more types are needed. **Never hand-edit the
generated output.**

**The federated scene renders end to end.** Getting there surfaced three
more defects, all of which produced a plausible-looking scene with
nothing in it: the generator sample's plant aggregation was created
under an already-registered `DeviceSet` and so was invisible to every
client (it had *never* rendered geometry, standalone or federated); the
connector's asset fetch ran on the primary session only, so composition
referenced layers that were never downloaded; and both generator colour
bindings wrote `primvars:displayColor` to prims that did not declare it,
so the renderer rejected every update while the file still looked
correct. Cross-server components compose under the subordinate's own
root, so the site layer now *places* `/Plant` and `/Powerhouse` rather
than leaving them stacked on the origin.

**`OpenUsdRepresentation` is now mounted with `HasAddIn`, not
`HasComponent`** (review feedback). The nodeset says so explicitly —
*"Mounted with HasAddIn"* — but every server in the tree used plain
`HasComponent`. It survived because `HasAddIn` is a *subtype* of
`HasComponent`: the representation still browses, still aggregates and
still drives a twin, so every functional test passed before the fix and
after it, and only a conformance checker could tell. Seven mount sites
corrected. `PumpDeviceIntegrationServer` and `MinimalRobotServer` had
hand-rolled the mount instead of calling the shared
`CreateRepresentation` helper, which is exactly how they drifted from
it. Both sample E2E suites now assert the reference type across
**every** discovered representation — a narrower check would have missed
the plant-level representation that arrived later from master still
carrying `HasComponent`.

**Live colour is not achievable in the current OpenUSD viewer, and that
is now documented rather than worked around.** Proven with a standalone
probe: `primvars:displayColor` resolves as `color3f[]` from the
`UsdGeomGprim` schema whatever the layer declares, and the `OpenUsd
0.4.0-alpha` managed API has no writer for that type. Bound
`UsdPreviewSurface` materials are not shaded either, so geometry renders
grey unless it also carries an explicit `displayColor` primvar. The
**visibility** bindings carry no such caveat and are what the samples
rely on to show state. Filed upstream as
[openusd-dotnet#2](https://github.com/marcschier/openusd-dotnet/issues/2),
[#3](https://github.com/marcschier/openusd-dotnet/issues/3) and
[#4](https://github.com/marcschier/openusd-dotnet/issues/4), and linked
from `docs/OpenUsd.md` so a reader can check whether they have since
been fixed.

## Related Issues

No tracking issue — this is additive sample and documentation work with
a single small, self-contained change to `src/` (per-component error
isolation and session-ownership hardening in
`OpenUsdConnector.Composition.cs`). Happy to open one if maintainers
would prefer it tracked.

## Checklist

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

### Testing

The generator fixtures live in `tests/Opc.Ua.OpenUsd.Tests/Generator` —
**82 tests**, no new project. `Opc.Ua.Di.Tests` would have been the
natural home next to the pump fixtures, but both device samples
source-generate their own reduced `Opc.Ua.Machinery` model and expose it
via `InternalsVisibleTo`, so referencing both from one assembly makes
every Machinery type ambiguous (CS0433); `Opc.Ua.OpenUsd.Tests`
references no other sample and already hosts `RobotAssetContractTests`.
The suite runs **870** on net10.0 and net48 (784 existing + 86) and
**788** on net472, where the sample does not exist and only the
framework-independent asset-contract tests run — forgetting exactly that
exclusion broke CI on the sibling pump PR.

The tests target the things in this sample that fail *quietly*:

- **Datasheet conformance** holds the model to its own claim — rated
speed is `120·f/p`, rated current follows from the rating, the fuel
curve reproduces the published table, and `η = P/(V̇·ρ·LHV)` reconciles
at every load. Efficiency is swept across the whole simulated range to
show it stays inside (0,100) and rises monotonically; a fit that goes
negative somewhere in its range is a coincidence that happens to look
right at the duty point, not a model. A further test reads
`DATASHEET.md` and checks the document quotes the figures the server
actually serves.
- **Drift between the two descriptions of the state machine** — the
physics' `IsLegalTransition` and the model's `GeneratorStateMap` — is
checked in *both* directions. A transition the physics permits but the
map lacks moves a machine without telling a client; one the map holds
but the physics refuses is dead weight that looks supported.
- **Reachability at run time**, not just in the declared table — the
test that catches states the model declares and nothing ever enters.
- **That every protection can actually fire**, which is the half of the
alarm contract that "a healthy set annunciates nothing" does not cover.

A **hosted end-to-end fixture** connects a real client and asserts what
the address space actually exposes — the aggregation at
`/Powerhouse/Generators`, its component binding, and every per-set twin.
That is the only kind of test that catches the registration defect
above: the node object had the right browse name, binding and asset
reference, so every test that inspected objects passed while no client
could see it.

**Every state-machine and protection fix was checked by mutation.**
Restoring the clamp ceiling, the direct `Running → Loaded` path, the old
start-counter placement, or removing the alarm opt-in each makes the
corresponding test fail, naming the specific defect. A test that has
never been seen to fail has not been shown to work.

Verified against a running plant: `Synchronizing`, `Paralleled` and
`Fault` all observed on a two-set server, with
`HighCoolantTemperatureAlarm` seen active on the fault subject. Method
behaviour verified separately — per-set distinct `CurrentState` nodes,
`EmergencyStop` → `EmergencyStopped` with `Start` then refused as
`BadInvalidState`, `ResetFaults` → `Off`, `SetOperatingMode(9999)`
refused with the mode unchanged, and the other sets untouched
throughout. Also verified as a three-server federated stage.

0 warnings on a clean Release rebuild of both samples, the client
library, the connector and the tests, across every TFM.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
2026-08-07 10:48:02 +02:00

736 lines
31 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Generators;
using Opc.Ua.OpenUsd;
using Opc.Ua.OpenUsd.Server;
namespace Generators
{
/// <summary>
/// The OpenUSD twin: one independent, live-driven 3D representation per
/// configured generator set.
/// </summary>
/// <remarks>
/// <para>
/// Each set owns a <see cref="GeneratorTwin"/> carrying its own prim path,
/// its own signal Variables and its own bindings, so <c>--generators N</c>
/// renders N machines that move independently.
/// </para>
/// <para>
/// One authoring rule dominates this file: a prim driven by a Translation,
/// Rotation or Scale render target must declare <c>xformOp:transform</c> in its
/// <c>xformOpOrder</c>, because a connector folds those targets into a single
/// matrix op and <c>xformOpOrder</c> is <c>uniform</c> - it cannot be rewritten
/// from the stronger layer the connector edits. Naming <c>xformOp:translate</c>
/// there instead makes USD discard every value silently, and every set renders
/// on the origin. The asset generator enforces this; a test pins it.
/// </para>
/// </remarks>
public partial class GeneratorNodeManager
{
private const string PowerhouseRootLayerIdentifier = "asset-repo/Powerhouse.usd";
private const string GeneratorsScopePrimPath = "/Powerhouse/Generators";
private const string GeneratorAssetReference = "@generator.usda@</Generator>";
private const double BaySpacingMetres = 6.0;
private const double SignalSamplingIntervalMilliseconds = 250;
private readonly ConcurrentDictionary<NodeId, GeneratorTwin> m_twins = new();
private (BaseVariableState Variable, Func<double> Getter)[] m_liveSignals = [];
private OpenUsdRootState? m_openUsdRoot;
private OpenUsdStageState? m_powerhouseStage;
/// <summary>
/// Creates the well-known OpenUSD facility and the powerhouse stage.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
private async ValueTask MaterialiseOpenUsdFacilityAsync(CancellationToken cancellationToken)
{
try
{
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
OpenUsdRootState root = SystemContext.CreateInstanceOfOpenUsdRootType(
null!, new QualifiedName("OpenUSD", ns));
root.NodeId = new NodeId("OpenUSD", ns);
FolderState stages = root.Stages
?? root.CreateOrReplaceStages(SystemContext, null!);
_ = root.Representations
?? root.CreateOrReplaceRepresentations(SystemContext, null!);
m_powerhouseStage = SystemContext.CreateInstanceOfOpenUsdStageType(
stages, new QualifiedName("PowerhouseStage", ns));
stages.AddChild(m_powerhouseStage);
m_powerhouseStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!)
.Value = PowerhouseRootLayerIdentifier;
// The digest covers the resolved root-layer content, never the
// identifier string, so a connector can detect tampering with the
// bytes it actually composes.
List<ServedAsset> servedAssets = LoadServedAssets();
byte[] rootLayerBytes = servedAssets
.Find(a => a.Kind == OpenUsdAssetKindEnum.RootLayer)!.Bytes;
byte[] digest;
#pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility)
using (var sha = System.Security.Cryptography.SHA256.Create())
{
digest = sha.ComputeHash(rootLayerBytes);
}
#pragma warning restore CA1850
m_powerhouseStage.CreateOrReplaceRootLayerDigest(SystemContext, null!)
.Value = (ByteString)digest;
m_powerhouseStage.CreateOrReplaceRootLayerDigestAlgorithm(SystemContext, null!)
.Value = OpenUsdDigestAlgorithmEnum.Sha256;
// The facility is a component of the Server Object so any conformant
// connector can browse Server -> OpenUSD -> Representations. The
// matching forward reference is added through externalReferences.
root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server);
UsdAssetDelivery.AttachStageAssets(SystemContext, m_powerhouseStage, ns, servedAssets);
SystemContext.AssignInstanceChildNodeIds(root);
await AddPredefinedNodeAsync(SystemContext, root, cancellationToken)
.ConfigureAwait(false);
m_openUsdRoot = root;
}
catch (Exception ex)
{
m_powerhouseStage = null;
m_openUsdRoot = null;
m_logger.LogError(ex, "Failed to materialise the OpenUSD facility.");
}
}
/// <summary>
/// Loads the embedded USD layers this server serves.
/// </summary>
/// <returns>The served asset closure.</returns>
private static List<ServedAsset> LoadServedAssets()
{
return
[
new ServedAsset(
"Powerhouse.usda",
OpenUsdAssetKindEnum.RootLayer,
ReadEmbeddedAsset("Powerhouse.usda")),
new ServedAsset(
"generator.usda",
OpenUsdAssetKindEnum.Reference,
ReadEmbeddedAsset("generator.usda")),
];
}
/// <summary>
/// Reads one embedded USD layer.
/// </summary>
/// <param name="resourceName">Logical resource name.</param>
/// <returns>The layer bytes, or an empty array when absent.</returns>
private static byte[] ReadEmbeddedAsset(string resourceName)
{
using Stream? s = typeof(GeneratorNodeManager).Assembly
.GetManifestResourceStream(resourceName);
if (s == null)
{
return [];
}
using var ms = new MemoryStream();
s.CopyTo(ms);
return ms.ToArray();
}
/// <inheritdoc/>
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
.ConfigureAwait(false);
LinkOpenUsdRootToServer(externalReferences);
}
/// <summary>
/// Adds the Server to OpenUSD forward reference through the shared
/// external-reference dictionary, so the facility is browsable from the
/// Server Object even though that node belongs to the core node manager.
/// </summary>
/// <param name="externalReferences">The shared reference dictionary.</param>
private void LinkOpenUsdRootToServer(
IDictionary<NodeId, IList<IReference>> externalReferences)
{
if (m_openUsdRoot == null)
{
return;
}
if (!externalReferences.TryGetValue(
Opc.Ua.ObjectIds.Server, out IList<IReference>? references))
{
references = new List<IReference>();
externalReferences[Opc.Ua.ObjectIds.Server] = references;
}
references.Add(new NodeStateReference(
ReferenceTypeIds.HasComponent, false, m_openUsdRoot.NodeId));
}
/// <summary>
/// Attaches one set's representation, its signal Variables and its live
/// bindings.
/// </summary>
/// <param name="set">The generator set.</param>
/// <param name="setNumber">One-based unit number, which fixes the bay.</param>
private void AttachOpenUsdRepresentation(GeneratorSetState set, int setNumber)
{
if (m_powerhouseStage == null)
{
return;
}
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
string primPath = PrimPathFor(set);
var twin = new GeneratorTwin(primPath, setNumber);
OpenUsdRepresentationState rep = SystemContext.CreateRepresentation(
set, m_powerhouseStage.NodeId, primPath, ns);
CreateSignals(set, twin);
CreateBindings(rep, ns, twin);
m_twins[set.NodeId] = twin;
twin.Representation = rep;
}
/// <summary>
/// Returns the prim path a set is composed at.
/// </summary>
/// <param name="set">The generator set.</param>
/// <returns>The prim path.</returns>
private static string PrimPathFor(GeneratorSetState set)
{
return FormattableString.Invariant($"{GeneratorsScopePrimPath}/{set.BrowseName.Name}");
}
/// <summary>
/// Creates the Variables that carry render-only signals - values the twin
/// needs that the companion specification does not model.
/// </summary>
/// <param name="set">The generator set.</param>
/// <param name="twin">The twin to populate.</param>
private void CreateSignals(GeneratorSetState set, GeneratorTwin twin)
{
twin.BayPosition = CreateVariable(
set,
"BayPosition",
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
Variant.From(new ExtensionObject(new ThreeDCartesianCoordinates
{
X = 0.0,
Y = (twin.SetNumber - 1) * BaySpacingMetres,
Z = 0.0,
})));
twin.FanAngle = CreateVariable(
set, "CoolingFanAngle", Opc.Ua.DataTypeIds.Double, Variant.From(0.0));
twin.LoadNeedle = CreateVariable(
set, "LoadGaugeAngle", Opc.Ua.DataTypeIds.Double, Variant.From(0.0));
twin.TempNeedle = CreateVariable(
set, "CoolantGaugeAngle", Opc.Ua.DataTypeIds.Double, Variant.From(0.0));
twin.ExhaustCelsius = CreateVariable(
set, "ExhaustSurfaceTemperature", Opc.Ua.DataTypeIds.Double, Variant.From(25.0));
twin.RadiatorCelsius = CreateVariable(
set, "RadiatorSurfaceTemperature", Opc.Ua.DataTypeIds.Double, Variant.From(25.0));
twin.FuelSurface = CreateVariable(
set,
"FuelSurfacePosition",
Opc.Ua.DataTypeIds.ThreeDCartesianCoordinates,
Variant.From(new ExtensionObject(FuelSurfaceAt(
GeneratorDatasheet.Simulation.InitialFuelPercent))));
twin.ProtectionTripped = CreateVariable(
set, "ProtectionTripped", Opc.Ua.DataTypeIds.Boolean, Variant.From(false));
twin.CoolantOverTemperature = CreateVariable(
set, "CoolantOverTemperature", Opc.Ua.DataTypeIds.Boolean, Variant.From(false));
twin.LowOilPressure = CreateVariable(
set, "LowOilPressure", Opc.Ua.DataTypeIds.Boolean, Variant.From(false));
twin.Running = CreateVariable(
set, "Running", Opc.Ua.DataTypeIds.Boolean, Variant.From(false));
twin.OperatingStateName = CreateVariable(
set, "OperatingStateName", Opc.Ua.DataTypeIds.String, Variant.From("Off"));
}
/// <summary>
/// Returns the fuel surface position for a tank level.
/// </summary>
/// <param name="levelPercent">Tank level, in percent.</param>
/// <returns>The surface position inside the tank.</returns>
/// <remarks>
/// The connector turns a scalar into a scalar, and a Translation target
/// needs a <c>double3</c>, so the surface height is published as a
/// structured coordinate rather than a bare number.
/// </remarks>
private static ThreeDCartesianCoordinates FuelSurfaceAt(double levelPercent)
{
const double tankFloor = -0.24;
const double tankHeight = 0.50;
return new ThreeDCartesianCoordinates
{
X = 0.0,
Y = 0.0,
Z = tankFloor + (tankHeight * Numeric.Clamp(levelPercent, 0.0, 100.0) / 100.0),
};
}
/// <summary>
/// Creates one render-only Variable on a set.
/// </summary>
/// <param name="set">Owning generator set.</param>
/// <param name="name">Browse and symbolic name.</param>
/// <param name="dataType">Variable data type.</param>
/// <param name="initialValue">Initial value.</param>
/// <returns>The created Variable.</returns>
private BaseDataVariableState CreateVariable(
GeneratorSetState set, string name, NodeId dataType, Variant initialValue)
{
var v = new BaseDataVariableState(set)
{
SymbolicName = name,
BrowseName = new QualifiedName(name, set.BrowseName.NamespaceIndex),
DisplayName = new LocalizedText(name),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
DataType = dataType,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = initialValue,
MinimumSamplingInterval = SignalSamplingIntervalMilliseconds,
};
set.AddChild(v);
v.NodeId = SystemContext.NodeIdFactory.New(SystemContext, v);
return v;
}
/// <summary>
/// Registers a set's hand-built signal Variables so monitored items sample
/// them, and adds its shaft angle to the publish set.
/// </summary>
/// <param name="set">The generator set.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <remarks>
/// Registers the one set passed in, not every set seen so far. This runs
/// once per set as it is created and the twin is already in the dictionary
/// by then, so walking the dictionary would re-register every earlier set
/// and publish the first one N times per tick.
/// </remarks>
private async ValueTask RegisterOpenUsdSignalsAsync(
GeneratorSetState set, CancellationToken cancellationToken)
{
if (!m_twins.TryGetValue(set.NodeId, out GeneratorTwin? twin))
{
return;
}
foreach (BaseDataVariableState? signal in twin.Signals)
{
if (signal != null)
{
await AddPredefinedNodeAsync(SystemContext, signal, cancellationToken)
.ConfigureAwait(false);
}
}
}
/// <summary>
/// Organises every set's representation into the discovery registry, so a
/// connector finds all of them rather than only the first, and declares the
/// plant-level aggregation that composes one generator prim per set.
/// </summary>
/// <remarks>
/// The aggregation is deliberately <em>not</em> declared dynamic. The set of
/// generators is fixed by <c>--generators</c> at start-up, and a dynamic
/// binding makes a connector's stale-prim sweep deactivate the component
/// prims that live under the same target prefix.
/// </remarks>
private async ValueTask OrganiseRepresentationsAsync(CancellationToken cancellationToken)
{
if (m_openUsdRoot?.Representations is FolderState registry)
{
foreach (GeneratorTwin twin in m_twins.Values)
{
twin.Representation?.RegisterInDiscovery(registry);
}
}
await MaterialisePlantAggregationAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Declares the DeviceSet-level representation whose single <c>Many</c>
/// component binding composes one referenced generator per configured set.
/// </summary>
/// <remarks>
/// The composition arc is <c>Reference</c>, not <c>Instance</c>: an
/// instanceable prim turns its descendants into a shared prototype, which
/// cannot carry the per-set rotation, colour and position that make the sets
/// look like independent machines.
/// </remarks>
/// <param name="cancellationToken">Cancellation token.</param>
private async ValueTask MaterialisePlantAggregationAsync(CancellationToken cancellationToken)
{
if (m_powerhouseStage == null || m_twins.IsEmpty)
{
return;
}
NodeState? deviceSet = FindPredefinedNode<NodeState>(NodeId.Create(
Opc.Ua.Di.Objects.DeviceSet,
DiNamespaceUri,
Server.NamespaceUris));
if (deviceSet == null)
{
return;
}
ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD);
OpenUsdRepresentationState plant = SystemContext.CreateRepresentation(
deviceSet, m_powerhouseStage.NodeId, GeneratorsScopePrimPath, ns);
plant.AddComponentBinding(
SystemContext,
ns,
"GeneratorSets",
new Guid("8f2a1c40-0100-4a3b-9c11-6d5e2f7a9b10"),
OpenUsdCardinalityEnum.Many,
OpenUsdCompositionArcEnum.Reference,
GeneratorsScopePrimPath,
componentRepresentation: default,
assetReference: GeneratorAssetReference,
dynamic: false,
changeEventSource: default,
componentServerUri: null,
componentEndpointUrl: null,
componentTypeDefinition: NodeId.Create(
Opc.Ua.Generators.ObjectTypes.GeneratorSetType,
Opc.Ua.Generators.Namespaces.Generators,
Server.NamespaceUris));
// DeviceSet was registered long before this runs, so the representation
// has to be registered on its own. Without this it exists only as a C#
// object hanging off an already-registered parent: RegisterInDiscovery
// below lists it, but no client can resolve or browse it, so a connector
// never sees the aggregation and never composes any generator geometry.
// The sets then render as live values on prims that have nothing behind
// them - an empty powerhouse that reports 250 kW.
SystemContext.AssignInstanceChildNodeIds(plant);
await AddPredefinedNodeAsync(SystemContext, plant, cancellationToken)
.ConfigureAwait(false);
if (m_openUsdRoot?.Representations is FolderState registry)
{
plant.RegisterInDiscovery(registry);
}
}
/// <summary>
/// Points a twin at the simulation that drives it.
/// </summary>
/// <param name="set">The generator set.</param>
/// <param name="simulation">The set's simulation.</param>
private void AttachSimulationToTwin(GeneratorSetState set, GeneratorSimulation simulation)
{
if (m_twins.TryGetValue(set.NodeId, out GeneratorTwin? twin))
{
twin.Simulation = simulation;
lock (m_registrationLock)
{
m_liveSignals =
[
.. m_liveSignals,
(twin.FanAngle!, () => twin.FanAngleDegrees),
(twin.LoadNeedle!, () => twin.LoadNeedleDegrees),
(twin.TempNeedle!, () => twin.TempNeedleDegrees),
(twin.ExhaustCelsius!, () => twin.Simulation?.ExhaustCelsius ?? 25.0),
(twin.RadiatorCelsius!, () => twin.Simulation?.CoolantCelsius ?? 25.0),
];
}
}
}
/// <summary>
/// Publishes the render-only signals of every set.
/// </summary>
/// <remarks>
/// Reads an immutable snapshot. A set can be added at runtime through
/// <c>CreateGeneratorSetAsync</c>, and mutating a list while the tick
/// enumerates it would throw on the timer thread and stop the simulation for
/// every set.
/// </remarks>
private void PublishOpenUsdSignals()
{
DateTime now = DateTime.UtcNow;
foreach ((BaseVariableState variable, Func<double> getter) in
Volatile.Read(ref m_liveSignals))
{
variable.Value = getter();
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
foreach (GeneratorTwin twin in m_twins.Values)
{
GeneratorSimulation? sim = twin.Simulation;
if (sim == null)
{
continue;
}
UpdateBoolean(twin.ProtectionTripped, sim.ProtectionTripped, now);
UpdateBoolean(twin.CoolantOverTemperature, sim.CoolantOverTemperature, now);
UpdateBoolean(twin.LowOilPressure, sim.LowOilPressure, now);
UpdateBoolean(twin.Running, sim.SpeedRpm > 100.0, now);
UpdateSurface(twin.FuelSurface, sim.FuelLevelPercent, now);
UpdateText(twin.OperatingStateName, sim.State.ToString(), now);
}
}
/// <summary>
/// Publishes a boolean indication.
/// </summary>
/// <param name="variable">The Variable to write.</param>
/// <param name="value">The value.</param>
/// <param name="now">Sample timestamp.</param>
private void UpdateBoolean(BaseDataVariableState? variable, bool value, DateTime now)
{
if (variable == null)
{
return;
}
variable.Value = value;
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
/// <summary>
/// Publishes a text indication.
/// </summary>
/// <param name="variable">The Variable to write.</param>
/// <param name="value">The value.</param>
/// <param name="now">Sample timestamp.</param>
private void UpdateText(BaseDataVariableState? variable, string value, DateTime now)
{
if (variable == null || Equals(variable.Value, value))
{
return;
}
variable.Value = value;
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
/// <summary>
/// Publishes the fuel surface position for a tank level.
/// </summary>
/// <param name="variable">The Variable to write.</param>
/// <param name="levelPercent">Tank level, in percent.</param>
/// <param name="now">Sample timestamp.</param>
private void UpdateSurface(BaseDataVariableState? variable, double levelPercent, DateTime now)
{
if (variable == null)
{
return;
}
variable.Value = new ExtensionObject(FuelSurfaceAt(levelPercent));
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
/// <summary>
/// One generator set's OpenUSD twin.
/// </summary>
/// <param name="primPath">Prim path the set is composed at.</param>
/// <param name="setNumber">One-based unit number.</param>
internal sealed class GeneratorTwin(string primPath, int setNumber)
{
/// <summary>
/// Degrees of needle travel across a full gauge dial.
/// </summary>
private const double GaugeSweepDegrees = 240.0;
/// <summary>
/// Gets the prim path the set is composed at.
/// </summary>
public string PrimPath { get; } = primPath;
/// <summary>
/// Gets the one-based unit number, which fixes the bay position.
/// </summary>
public int SetNumber { get; } = setNumber;
/// <summary>
/// Gets or sets the representation add-in for this set.
/// </summary>
public OpenUsdRepresentationState? Representation { get; set; }
/// <summary>
/// Gets or sets the simulation driving this set.
/// </summary>
public GeneratorSimulation? Simulation { get; set; }
/// <summary>
/// Gets or sets the bay-position signal.
/// </summary>
public BaseDataVariableState? BayPosition { get; set; }
/// <summary>
/// Gets or sets the cooling-fan angle signal.
/// </summary>
public BaseDataVariableState? FanAngle { get; set; }
/// <summary>
/// Gets or sets the load-gauge needle angle signal.
/// </summary>
public BaseDataVariableState? LoadNeedle { get; set; }
/// <summary>
/// Gets or sets the coolant-gauge needle angle signal.
/// </summary>
public BaseDataVariableState? TempNeedle { get; set; }
/// <summary>
/// Gets or sets the exhaust surface temperature signal, in degrees Celsius.
/// </summary>
public BaseDataVariableState? ExhaustCelsius { get; set; }
/// <summary>
/// Gets or sets the radiator surface temperature signal, in degrees Celsius.
/// </summary>
public BaseDataVariableState? RadiatorCelsius { get; set; }
/// <summary>
/// Gets or sets the fuel surface position signal.
/// </summary>
public BaseDataVariableState? FuelSurface { get; set; }
/// <summary>
/// Gets or sets the protection-tripped indication.
/// </summary>
public BaseDataVariableState? ProtectionTripped { get; set; }
/// <summary>
/// Gets or sets the coolant over-temperature indication.
/// </summary>
public BaseDataVariableState? CoolantOverTemperature { get; set; }
/// <summary>
/// Gets or sets the low oil pressure indication.
/// </summary>
public BaseDataVariableState? LowOilPressure { get; set; }
/// <summary>
/// Gets or sets the running indication.
/// </summary>
public BaseDataVariableState? Running { get; set; }
/// <summary>
/// Gets or sets the operating state name shown on the twin.
/// </summary>
public BaseDataVariableState? OperatingStateName { get; set; }
/// <summary>
/// Gets every signal Variable this twin owns.
/// </summary>
public IEnumerable<BaseDataVariableState?> Signals =>
[
BayPosition, FanAngle, LoadNeedle, TempNeedle, ExhaustCelsius,
RadiatorCelsius, FuelSurface, ProtectionTripped, CoolantOverTemperature,
LowOilPressure, Running, OperatingStateName,
];
/// <summary>
/// Gets the integrated cooling-fan angle, in degrees.
/// </summary>
public double FanAngleDegrees { get; private set; }
/// <summary>
/// Gets the load-gauge needle angle, in degrees.
/// </summary>
public double LoadNeedleDegrees =>
-(GaugeSweepDegrees / 2.0) + (GaugeSweepDegrees *
Numeric.Clamp((Simulation?.LoadFraction ?? 0.0) / 1.2, 0.0, 1.0));
/// <summary>
/// Gets the coolant-gauge needle angle, in degrees.
/// </summary>
public double TempNeedleDegrees =>
-(GaugeSweepDegrees / 2.0) + (GaugeSweepDegrees * Numeric.Clamp(
(Simulation?.CoolantCelsius ?? 0.0) / GeneratorDatasheet.Ranges.CoolantMaxCelsius,
0.0,
1.0));
/// <summary>
/// Degrees per second the fan is *shown* turning at rated speed.
/// </summary>
/// <remarks>
/// Deliberately not the real rate. A fan on a 1500 rpm engine turns at
/// 9000 degrees per second; sampled at the tick interval that is several
/// revolutions per sample, so the published angle jumps by a near-arbitrary
/// amount each time and the blades either strobe or sit still. Neither
/// tells an operator the fan is running, which is the only thing this
/// signal exists to say. The display rate is chosen so the per-tick step
/// stays well under the blade pitch and the rotation reads as continuous.
/// </remarks>
private const double FanDisplayDegreesPerSecond = 52.0;
/// <summary>
/// Integrates the cooling-fan angle from engine speed.
/// </summary>
/// <param name="seconds">Elapsed time since the previous tick.</param>
/// <remarks>
/// The angle is integrated rather than sampled so the blades turn smoothly
/// instead of jumping, and it is scaled to a rate a viewport can actually
/// resolve - see <see cref="FanDisplayDegreesPerSecond"/>.
/// </remarks>
public void AdvanceFan(double seconds)
{
double rpm = Simulation?.SpeedRpm ?? 0.0;
double fraction = rpm / GeneratorDatasheet.Engine.RatedSpeedRpm;
FanAngleDegrees =
(FanAngleDegrees + (fraction * FanDisplayDegreesPerSecond * seconds)) % 360.0;
}
}
}