Files
UA-.NETStandard/samples/OpenUsd/GeneratorServer/GeneratorNodeManager.Configure.cs
romanett 668310f21f Make the fluent node manager's configure/seal path genuinely async (#4442)
# Description

The fluent node-manager pipeline had two synchronous chokepoints, and
two independent efforts have now hit both of them.

`Configure` is a `partial void`, so it cannot `await`. A manager whose
wiring depends on asynchronous setup had to invent a second hook outside
the pipeline, create its own builder there, and hand-roll
`CreateFluentBuilder(...).Configure(...).Seal()` inside it.

`Seal()` was synchronous, so nothing a registration staged during
`Configure` could be completed by awaiting. Registrations that needed to
await either blocked — `EventSourceRegistry` did
`.GetAwaiter().GetResult()` on the manager's root-notifier registration
— or could not be expressed at all.

## What changed

**`FluentNodeManagerBase.ConfigureAsync(INodeManagerBuilder,
CancellationToken)`** is the awaitable wiring seam. It runs once per
activation, with the manager's attached builder, immediately *before*
the synchronous `Configure` partial(s) — so instances it materialises
are in the address space by the time `Configure` wires callbacks against
them. The default implementation is a no-op.

It is a `virtual` method rather than a second `partial` declaration
because a partial method can be optional (`partial void`, no return
value) or awaitable (an extended partial method, which must be
implemented) — not both, and the generator would otherwise have to
detect which one the user declared. `partial void
Configure(INodeManagerBuilder)` keeps working unchanged; a manager uses
either hook or both.

**`NodeManagerBuilder.Seal()` becomes `SealAsync(CancellationToken)`.**
Sealing is now the single point where registrations that could not
finish inside `Configure` get their turn to await. Every seal site
awaits it, so a later change can move registry *setup* onto the same
seam.

**`DiNodeManager` now runs the standard pipeline itself** — create and
attach the builder, `await ConfigureAsync`,
`RegisterAuthoredNodesAsync`, `CompleteConfigureAsync`, the post-setup
runner, then `await builder.SealAsync` — instead of handing subclasses a
bare callback and leaving them to build and seal on their own. DI
subclasses gain node registration and the reverse-reference pass, which
they previously skipped.

Sealing deliberately comes **after** the post-setup runner. The fluent
registries are owned by the manager, not by a single builder, and
sealing starts them — so sealing first locks post-setup configurators
out of registering simulation loops of their own, which is exactly what
`AddRobotics`' configurators do. Sealing before the runner makes
`MinimalRobotServer` fail to start with *"Cannot add a simulation loop
after the registry has started"*. Worth knowing for follow-up work: with
several builders attached to one manager, the first seal freezes the
shared registry for all of them.

## Workarounds removed

- **`DiNodeManager.OnAddressSpaceReadyAsync`** — deleted. Its three
overriders (`RoboticsNodeManager`, and the `PumpNodeManager` /
`GeneratorNodeManager` samples) now override `ConfigureAsync` and no
longer hand-roll `CreateFluentBuilder(...).Configure(Configure).Seal()`.
- **`EventSourceRegistry`'s `.GetAwaiter().GetResult()`** on
`AddRootNotifierAsync` — gone. `Publish(..., RegisterAsRootNotifier:
true)` now stages the registration and `SealAsync` drains it, so the
wait happens on an awaited path. A source whose root-notifier
registration fails is rolled out of the registry before the failure
surfaces, and a registry disposed before its builder sealed drops what
it staged.
- **`Isa95NodeManager.ConfigureStatusEvents()`** — the synchronous
second builder pass is now
`ConfigureStatusEventsAsync(CancellationToken)`, awaited from
`CreateAddressSpaceAsync`. (`ConfigureCatalogChanges()` stays
synchronous: it starts a background task and never seals a builder, so
there was nothing to convert.)
- **`IRoboticsBuildContext.Seal()`** — now
`SealAsync(CancellationToken)`. These really were
`NodeManagerBuilder.Seal()` calls behind a same-named method. The state
transition still happens under the context lock; the builder's
asynchronous completion runs outside it, so no `await` is taken while
the lock is held.
- The Pump / Generator incremental registration callbacks
(`onRegistered`) changed from `Action<T>` to `Func<T, CancellationToken,
ValueTask>` so their per-instance builder pass can await its own seal.

No sync-over-async was introduced anywhere. `Dispose(bool)` is unchanged
and stays non-blocking; teardown remains in `DeleteAddressSpaceAsync`.

`NodeManagerBuilder.Seal()` and `IRoboticsBuildContext.Seal()` are
removed rather than kept as synchronous overloads. A synchronous seal
that silently skipped the asynchronous completion would reintroduce
exactly the failure mode this change exists to remove.

## Generator

`NodeManagerTemplates` emits, in order: predefined nodes load → `await
ConfigureAsync(__m_builder, cancellationToken)` →
`Configure(__m_builder)` and the typed `Configure` →
`RegisterAuthoredNodesAsync` → `CompleteConfigureAsync` → `await
SealConfigurationAsync(__m_builder, cancellationToken)`.

`NodeManagerGeneratorTests` asserts that order literally, and that
neither `__m_builder.Seal()` nor the synchronous
`SealConfiguration(__m_builder)` is emitted.

### Merging with #4432

#4432 landed on `master` while this was in review and reworked the same
seam from the other side: it split `Seal()` into `SealGraphAuthoring()`
+ `StartSimulations()` so a manager can replay `NotifyNodeAdded`
*between* the two halves — sealing first stops a lifecycle handler
authoring nodes nothing would register, starting the simulations last
stops a simulated value change preceding its own node''s `OnNodeAdded` —
and added `FluentNodeManagerBase.SealConfiguration(builder)` as the one
call the generator emits.

Both intents are kept. That split is precisely why the asynchronous
completion could not simply live inside `SealAsync`: a manager reaching
the seam through the split path would silently skip it, which is the
failure mode this PR exists to remove. So the asynchronous half became
its own step:

- `NodeManagerBuilder.CompleteSealAsync(ct)` drains the staged
root-notifier registrations, then starts the simulations.
`SealAsync(ct)` is `SealGraphAuthoring()` followed by it.
- #4432''s synchronous `StartSimulations()` is gone, folded into
`CompleteSealAsync`. It had exactly one production caller after the
merge, and leaving it standing would have kept a second, synchronous way
to activate a builder that skips the staged registrations — the very
hazard this PR removes. Activation is now one step with one entry point;
the *split* itself stays, because the replay has to sit between the
halves.
- `SealConfiguration` becomes `SealConfigurationAsync(builder, ct)`:
seal the graph, replay `NotifyNodeAdded`, then `CompleteSealAsync`.
Activation still happens last, so #4432''s ordering guarantee holds
unchanged.

## Consumers unblocked

- **#4434 ("Reimplement the GDS ApplicationsNodeManager as a generated
fluent node manager")** adds `OnAddressSpaceReadyAsync` to
`FluentNodeManagerBase` purely because, in its own words, *"`Configure`
is a `partial void` and cannot await"*. That hook is unnecessary once
`ConfigureAsync` exists — the GDS manager overrides `ConfigureAsync`
instead, and the ordering it wants (predefined nodes → async setup →
`Configure`) is what the generator now emits. #4434 has not merged yet,
so it can drop its addition rather than have it simplified afterwards.
- **Branch `romanett/fluent-node-behaviors`** moved
`SimulationRegistry`, `EventSourceRegistry`, `MonitoredSourceRegistry`
and the alarm wiring onto `AttachToType`/`Attach` for *release* only,
because activation ran from `CompleteConfigureAsync` and most managers
only called the synchronous `Seal()`. Every seal site now reaches
`SealAsync`, so registry and alarm *setup* can move onto behavior
activation too — the remaining half of that work.

## Tests

- New `DiNodeManagerConfigureAsyncTests` drives the real
`DiNodeManager.CreateAddressSpaceAsync` and asserts the hook runs once
with an attached builder, that nodes it stages are registered, that
their references to externally owned nodes are published, and that the
builder is sealed before `CreateAddressSpaceAsync` returns.
- `PublishTests` now pins the root-notifier deferral contract:
registration is staged during `Configure`, lands only on the seal, and
draining twice does not double-register.
- Every `Seal()` call site in the test suites became `await
...SealAsync()`, including the three #4432 added in
`NodeSetImportBuilderTests` and `SimulationBuilderExtensionsTests`.

## Related Issues

No tracking issue was opened for this; it is the shared prerequisite
extracted from #4434 and branch `romanett/fluent-node-behaviors`, both
of which had independently worked around the same synchrony. Happy to
open one if maintainers want the ADR trail.

## 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.
- [ ] 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.

### What was actually run locally

`UA.slnx` builds clean. Post-merge on **net10.0**: `Opc.Ua.Server.Tests`
5020/0 and `Opc.Ua.SourceGeneration.Core.Tests` 3813/0. Pre-merge the
same run plus `Opc.Ua.Di.Tests` 425/0, `Opc.Ua.Robotics.Tests` 627/0,
`Opc.Ua.ISA95.Tests` 136/0, `Opc.Ua.Positioning.Tests` and
`Opc.Ua.Robotics.Intent.Tests` were all green; `MinimalRobotServer` was
also started directly to confirm the hosted robotics path comes up.

Three caveats, so the unchecked box above is not a mystery: the full
suite was **not** run against .NET Framework locally;
`Opc.Ua.OpenUsd.Tests` has one pre-existing failure on this machine
(`StructuredCartesianCoordinatesAreAccepted` expects `"1.5"` and gets
`"1,5"` — a German-locale decimal separator, unrelated to this change);
and one pre-existing `CA1861` warning comes from
`NodeSetImportIntegrationTests.cs`, which is byte-identical to `master`
on this branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:01:56 +00:00

398 lines
19 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 System.Threading.Tasks;
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>
/// <param name="cancellationToken">Cancellation token.</param>
private ValueTask RegisterGeneratorSimulationAsync(
GeneratorSetState set,
CancellationToken cancellationToken)
{
ushort ns = (ushort)Server.NamespaceUris.GetIndex(
Opc.Ua.Generators.Namespaces.Generators);
NodeManagerBuilder builder = CreateFluentBuilder(ns);
RegisterGeneratorSimulation(builder, set);
return builder.SealAsync(cancellationToken);
}
/// <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);
}
}