Files
UA-.NETStandard/samples/GeneratorServer/GeneratorNodeManager.Configure.cs
Marc Schier 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

394 lines
18 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.Threading;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Generators;
using Opc.Ua.Server.Fluent;
namespace Generators
{
/// <summary>
/// The datasheet-driven simulation, the operating state machine and the
/// protection alarms.
/// </summary>
public partial class GeneratorNodeManager
{
private readonly ConcurrentDictionary<NodeId, GeneratorSimulation> m_simulations = new();
private readonly Lock m_registrationLock = new();
/// <summary>
/// Serialises the simulation tick against client method calls.
/// </summary>
/// <remarks>
/// The tick runs on a thread-pool thread and method calls arrive on request
/// threads, and both drive state transitions and write the same address-space
/// nodes. One gate for the whole plant rather than one per set: a tick is
/// microseconds of arithmetic, so the contention is irrelevant, and a single
/// gate cannot be taken in the wrong order.
/// </remarks>
private readonly Lock m_simulationGate = new();
private long m_ticks;
private int m_nextProfile;
private GeneratorSimulation? m_faultSubject;
/// <summary>
/// Seconds between the start of one fault cycle and the next.
/// </summary>
private const double FaultCycleSeconds = 150.0;
/// <summary>
/// Seconds a fault is left in place, long enough to trip and annunciate.
/// </summary>
private const double FaultDwellSeconds = 20.0;
/// <summary>
/// The faults the schedule rotates through, one per cycle, so a long run
/// exercises every protection rather than the same one repeatedly.
/// </summary>
private static readonly GeneratorFault[] s_faultRotation =
[
GeneratorFault.CoolingFailure,
GeneratorFault.OilPressureLoss,
GeneratorFault.Overload,
GeneratorFault.GovernorFailure,
];
/// <summary>
/// Wires the simulation for every materialised set and starts the tick.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
partial void Configure(INodeManagerBuilder builder)
{
foreach (GeneratorSetState set in m_generatorSets)
{
RegisterGeneratorSimulation(builder, set);
}
builder.Simulation(SimulationInterval)
.OnTick((ctx, elapsed) => AdvanceSimulation());
}
/// <summary>
/// Wires a set created after the initial fluent configuration into the
/// already-running simulation.
/// </summary>
/// <param name="set">The registered generator set.</param>
private void RegisterGeneratorSimulation(GeneratorSetState set)
{
ushort ns = (ushort)Server.NamespaceUris.GetIndex(
Opc.Ua.Generators.Namespaces.Generators);
NodeManagerBuilder builder = CreateFluentBuilder(ns);
RegisterGeneratorSimulation(builder, set);
builder.Seal();
}
/// <summary>
/// Binds every measured variable of one set and records its simulation state.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
/// <param name="set">The generator set to wire.</param>
private void RegisterGeneratorSimulation(INodeManagerBuilder builder, GeneratorSetState set)
{
int profile;
lock (m_registrationLock)
{
profile = m_nextProfile++;
}
EngineState engine = set.Engine!;
AlternatorState alternator = set.Alternator!;
// Engine.
Analog(builder, engine.Speed!.NodeId, EngineeringUnits.RevolutionsPerMinute,
0, GeneratorDatasheet.Ranges.SpeedMaxRpm, out IValueUpdater<double> speed);
Analog(builder, engine.OilPressure!.NodeId, EngineeringUnits.Pascal,
0, GeneratorDatasheet.Convert.ToPascal(GeneratorDatasheet.Ranges.OilPressureMaxBar),
out IValueUpdater<double> oilPressure);
Analog(builder, engine.CoolantTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.CoolantMaxCelsius),
out IValueUpdater<double> coolantTemperature);
Analog(builder, engine.ExhaustGasTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.ExhaustMaxCelsius),
out IValueUpdater<double> exhaustTemperature);
Analog(builder, engine.FuelRate!.NodeId, EngineeringUnits.CubicMetrePerSecond,
0,
GeneratorDatasheet.Convert.ToCubicMetresPerSecond(
GeneratorDatasheet.Ranges.FuelRateMaxLitresPerHour),
out IValueUpdater<double> fuelRate);
Analog(builder, engine.EngineHours!.NodeId, EngineeringUnits.Hour,
0, 100_000, out IValueUpdater<double> engineHours);
Analog(builder, engine.PercentLoad!.NodeId, EngineeringUnits.Percent,
0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater<double> percentLoad);
builder.Variable<uint>(engine.NumberOfStarts!.NodeId)
.Bind(out IValueUpdater<uint> starts);
// Alternator aggregates.
Analog(builder, alternator.Frequency!.NodeId, EngineeringUnits.Hertz,
GeneratorDatasheet.Ranges.FrequencyMinHertz,
GeneratorDatasheet.Ranges.FrequencyMaxHertz, out IValueUpdater<double> frequency);
Analog(builder, alternator.TotalRealPower!.NodeId, EngineeringUnits.Watt,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater<double> realPower);
Analog(builder, alternator.TotalApparentPower!.NodeId, EngineeringUnits.VoltAmpere,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts / GeneratorDatasheet.Electrical.RatedPowerFactor,
out IValueUpdater<double> apparentPower);
Analog(builder, alternator.AverageLineToLineVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.VoltageMaxVolts, out IValueUpdater<double> voltage);
Analog(builder, alternator.AverageCurrent!.NodeId, EngineeringUnits.Ampere,
0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater<double> current);
Analog(builder, alternator.LoadPercent!.NodeId, EngineeringUnits.Percent,
0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater<double> loadPercent);
Analog(builder, alternator.TotalRealEnergy!.NodeId, EngineeringUnits.WattHour,
0, 1e9, out IValueUpdater<double> energy);
builder.Variable<double>(alternator.AveragePowerFactor!.NodeId)
.Bind(out IValueUpdater<double> powerFactor);
// Per-phase values.
var phases = new PhaseUpdaters[3];
AlternatorPhaseState[] phaseStates = [alternator.L1!, alternator.L2!, alternator.L3!];
for (int i = 0; i < phaseStates.Length; i++)
{
AlternatorPhaseState phase = phaseStates[i];
Analog(builder, phase.LineToNeutralVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.VoltageMaxVolts, out IValueUpdater<double> pv);
Analog(builder, phase.Current!.NodeId, EngineeringUnits.Ampere,
0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater<double> pi);
Analog(builder, phase.RealPower!.NodeId, EngineeringUnits.Watt,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater<double> pp);
builder.Variable<double>(phase.PowerFactor!.NodeId)
.Bind(out IValueUpdater<double> ppf);
phases[i] = new PhaseUpdaters(pv, pi, pp, ppf);
}
// Balance of plant.
Analog(builder, set.FuelSystem!.FuelLevel!.NodeId, EngineeringUnits.Percent,
0, 100, out IValueUpdater<double> fuelLevel);
Analog(builder, set.FuelSystem!.TotalFuelConsumed!.NodeId, EngineeringUnits.Litre,
0, 1e6, out IValueUpdater<double> fuelConsumed);
Analog(builder, set.CoolingSystem!.AmbientTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(-40),
GeneratorDatasheet.Convert.ToKelvin(60), out IValueUpdater<double> ambient);
Analog(builder, set.LubricationSystem!.OilTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(150), out IValueUpdater<double> oilTemperature);
Analog(builder, set.StartingSystem!.BatteryVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.BatteryVoltageMaxVolts,
out IValueUpdater<double> batteryVoltage);
builder.Variable<bool>(set.GeneratorBreakerClosed!.NodeId)
.Bind(out IValueUpdater<bool> breakerClosed);
builder.Variable<bool>(set.AvailableToLoad!.NodeId)
.Bind(out IValueUpdater<bool> availableToLoad);
var simulation = new GeneratorSimulation(
profile,
SimulationInterval,
new EngineUpdaters(
speed, oilPressure, coolantTemperature, exhaustTemperature,
fuelRate, engineHours, percentLoad, starts),
new AlternatorUpdaters(
frequency, realPower, apparentPower, voltage, current,
loadPercent, energy, powerFactor, phases),
new PlantUpdaters(
fuelLevel, fuelConsumed, ambient, oilTemperature,
batteryVoltage, breakerClosed, availableToLoad));
m_simulations[set.NodeId] = simulation;
// The first set registered is the one the fault schedule drives. It is
// the machine nearest the hero camera, so a trip is visible in the
// viewport rather than happening to a set that is off the far end of
// the row - an alarm nobody can see demonstrates nothing.
m_faultSubject ??= simulation;
AttachSimulationToTwin(set, simulation);
AttachProtectionAlarms(set, simulation);
AttachStateMachine(set, simulation);
}
/// <summary>
/// Lets one set develop a fault on a slow rotation, so the protection
/// path is exercised in a running plant.
/// </summary>
/// <param name="tick">Monotonic tick counter.</param>
/// <remarks>
/// <para>
/// A set running to its datasheet cannot protect-trip: the healthy curves
/// are bounded well inside every trip point, which is what a datasheet
/// means. Without a deliberate excursion the four alarms, the shutdown
/// class, <c>ResetFaults</c> and the whole Fault branch of the state machine
/// would be code that never runs - and a sample that documents alarms which
/// never fire is worse than one that has none.
/// </para>
/// <para>
/// Confined to a single set so the rest of the plant stays a clean
/// reference, and disabled entirely with <c>--faults false</c>.
/// </para>
/// </remarks>
private void DriveFaultSchedule(long tick)
{
if (!InjectFaults || m_simulations.IsEmpty)
{
return;
}
double seconds = tick * SimulationInterval.TotalSeconds;
int slot = (int)(seconds / FaultCycleSeconds);
double intoSlot = seconds - (slot * FaultCycleSeconds);
// Healthy for most of each cycle, then one fault long enough to trip,
// annunciate and shut the set down before the next cycle clears it.
GeneratorFault fault = intoSlot < FaultCycleSeconds - FaultDwellSeconds
? GeneratorFault.None
: s_faultRotation[slot % s_faultRotation.Length];
GeneratorSimulation? subject = m_faultSubject;
if (subject != null && subject.Fault != fault)
{
subject.InjectFault(fault);
}
}
/// <summary>
/// Advances every set by one tick.
/// </summary>
/// <remarks>
/// Runs on a thread-pool thread while client requests are served on their
/// own threads, so it is serialised against the method handlers by
/// <see cref="m_simulationGate"/>. Without that, a tick and a concurrent
/// EmergencyStop can both transition the same set and race on the paired
/// CurrentState / CurrentState.Id write, leaving a client with a state name
/// that does not match the state node beside it.
/// </remarks>
private void AdvanceSimulation()
{
long tick = Interlocked.Increment(ref m_ticks);
double seconds = SimulationInterval.TotalSeconds;
lock (m_simulationGate)
{
foreach (GeneratorSimulation simulation in m_simulations.Values)
{
simulation.Advance(tick);
}
DriveFaultSchedule(tick);
foreach (GeneratorTwin twin in m_twins.Values)
{
twin.AdvanceFan(seconds);
}
EvaluateProtections();
}
PublishOpenUsdSignals();
}
/// <summary>
/// Binds an analog variable with its engineering units and range.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
/// <param name="nodeId">The variable to bind.</param>
/// <param name="units">Engineering units published as a property.</param>
/// <param name="min">Lower bound of the engineering range.</param>
/// <param name="max">Upper bound of the engineering range.</param>
/// <param name="updater">Receives the updater the simulation writes through.</param>
private static void Analog(
INodeManagerBuilder builder,
NodeId nodeId,
EUInformation units,
double min,
double max,
out IValueUpdater<double> updater)
{
builder.Variable<double>(nodeId)
.Bind(out updater)
.WithEngineeringUnits(units)
.WithEURange(min, max);
}
/// <summary>
/// The UNECE/CEFACT units this sample publishes.
/// </summary>
/// <remarks>
/// The companion specification types every measured value as
/// <c>AnalogUnitType</c> carrying a machine-readable unit, so a client can
/// discover the unit from the model alone rather than guessing from a name.
/// </remarks>
private static class EngineeringUnits
{
private const string Cefact = "http://www.opcfoundation.org/UA/units/un/cefact";
public static readonly EUInformation RevolutionsPerMinute =
new("r/min", "Revolutions per Minute", Cefact);
public static readonly EUInformation Pascal = new("Pa", "Pascal", Cefact);
public static readonly EUInformation Kelvin = new("K", "Kelvin", Cefact);
public static readonly EUInformation CubicMetrePerSecond =
new("m3/s", "Cubic Metre per Second", Cefact);
public static readonly EUInformation Hour = new("h", "Hour", Cefact);
public static readonly EUInformation Percent = new("%", "Percent", Cefact);
public static readonly EUInformation Hertz = new("Hz", "Hertz", Cefact);
public static readonly EUInformation Watt = new("W", "Watt", Cefact);
public static readonly EUInformation VoltAmpere = new("VA", "Volt Ampere", Cefact);
public static readonly EUInformation Volt = new("V", "Volt", Cefact);
public static readonly EUInformation Ampere = new("A", "Ampere", Cefact);
public static readonly EUInformation WattHour = new("W h", "Watt Hour", Cefact);
public static readonly EUInformation Litre = new("l", "Litre", Cefact);
}
}
/// <summary>
/// Source-generated log messages for the generator simulation.
/// </summary>
internal static partial class GeneratorSimulationLog
{
[LoggerMessage(
EventId = GeneratorServerEventIds.GeneratorSimulation + 0,
Level = LogLevel.Debug,
Message = "Generator set {NodeId} entered state {State}.")]
public static partial void GeneratorStateChanged(
this ILogger logger, NodeId nodeId, GeneratorRunState state);
}
}