/* ========================================================================
* 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.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Pumps;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.Historian;
namespace Pumps
{
///
/// Sibling partial that wires per-node callbacks for the
/// using the fluent builder.
/// Demonstrates the OPC 40223 Pumps companion specification
/// with a full simulation loop.
///
///
/// The node manager is hand-written and uses the fluent
/// API to wire per-node
/// callbacks. See for the entry point.
///
public partial class PumpNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
Server.Telemetry.CreateLogger()
.ConfiguringPumpNodeManagerFluentWiring();
builder.UseHistorian()
.UseInMemoryProvider()
.RegisterAsDefault();
foreach (PumpState pump in m_pumpStates)
{
RegisterPumpSimulation(builder, pump);
}
builder.Simulation(SimulationInterval)
.OnTick((ctx, elapsed) => AdvanceSimulation());
}
///
/// Wires a pump created after the initial fluent configuration into
/// the already-running manager simulation.
///
/// The registered pump instance.
/// Cancellation token.
private ValueTask RegisterPumpSimulationAsync(
PumpState pump,
CancellationToken cancellationToken)
{
ushort pumpsNs = (ushort)Server.NamespaceUris.GetIndex(
Opc.Ua.Pumps.Namespaces.Pumps);
NodeManagerBuilder builder = CreateFluentBuilder(pumpsNs);
RegisterPumpSimulation(builder, pump);
return builder.SealAsync(cancellationToken);
}
///
/// Configures the variable updaters, alarms, and phase profile for one
/// pump instance.
///
/// The active fluent builder.
/// The pump to configure.
///
private void RegisterPumpSimulation(
INodeManagerBuilder builder,
PumpState pump)
{
lock (m_simulationRegistrationLock)
{
if (m_pumpSimulations.ContainsKey(pump.NodeId))
{
throw ServiceResultException.Create(
StatusCodes.BadConfigurationError,
"Pump '{0}' (id '{1}') already has a simulation registered.",
pump.BrowseName,
pump.NodeId);
}
int profileIndex = m_nextSimulationProfile++;
WithIdentification(builder, pump, profileIndex + 1);
WithMaintenance(builder, pump);
PumpSimulationState simulation = CreatePumpSimulation(
builder,
pump,
profileIndex);
simulation.Initialize(Volatile.Read(ref m_simulationTicks));
if (!m_pumpSimulations.TryAdd(pump.NodeId, simulation))
{
throw ServiceResultException.Create(
StatusCodes.BadConfigurationError,
"Pump '{0}' (id '{1}') already has a simulation registered.",
pump.BrowseName,
pump.NodeId);
}
// The OpenUSD twin of this pump renders this simulation.
if (m_twins.TryGetValue(pump.NodeId, out PumpTwin? twin))
{
twin.Simulation = simulation;
}
}
}
///
/// Configures the nameplate of one simulated unit with the
/// identification data published in DATASHEET.md. The
/// properties themselves are materialised by
/// ; this method
/// only assigns their values through the fluent builder. Fields
/// that identify the individual unit rather than the product are
/// derived from .
///
/// The active fluent builder.
/// The pump whose nameplate is configured.
/// One-based number of the simulated unit.
private static void WithIdentification(
INodeManagerBuilder builder,
PumpState pump,
int pumpNumber)
{
string serialNumber = "SN-" + pumpNumber.ToString("D3", CultureInfo.InvariantCulture);
string assetId = "PMP-" +
(1000 + pumpNumber).ToString(CultureInfo.InvariantCulture);
string componentName = "Feed Pump " + (char)('A' + ((pumpNumber - 1) % 26));
string location = "Plant 1 / Utility Skid / Bay " +
(pumpNumber + 2).ToString(CultureInfo.InvariantCulture);
string fabricationNumber = "F-2025-" +
pumpNumber.ToString("D4", CultureInfo.InvariantCulture);
builder.Node(pump.Identification!.NodeId)
.WithProperty(
"Manufacturer",
new LocalizedText(PumpDatasheet.Nameplate.Manufacturer))
.WithProperty(
"ManufacturerUri",
PumpDatasheet.Nameplate.ManufacturerUri)
.WithProperty(
"Model",
new LocalizedText(PumpDatasheet.Nameplate.Model))
.WithProperty("ProductCode", PumpDatasheet.Nameplate.ProductCode)
.WithProperty("DeviceClass", PumpDatasheet.Nameplate.DeviceClass)
.WithProperty(
"HardwareRevision",
PumpDatasheet.Nameplate.HardwareRevision)
.WithProperty(
"SoftwareRevision",
PumpDatasheet.Nameplate.SoftwareRevision)
.WithProperty("SerialNumber", serialNumber)
.WithProperty(
"ProductInstanceUri",
PumpDatasheet.Nameplate.ProductInstanceUriPrefix + serialNumber)
.WithProperty("AssetId", assetId)
.WithProperty("ComponentName", new LocalizedText(componentName))
.WithProperty("Location", location)
.WithProperty(
"YearOfConstruction",
PumpDatasheet.Nameplate.YearOfConstruction)
.WithProperty(
"MonthOfConstruction",
PumpDatasheet.Nameplate.MonthOfConstruction)
.WithProperty(
"DayOfConstruction",
PumpDatasheet.Nameplate.DayOfConstruction)
.WithProperty(
"ArticleNumber",
PumpDatasheet.Nameplate.ArticleNumber)
.WithProperty(
"OrderProductCode",
PumpDatasheet.Nameplate.OrderProductCode)
.WithProperty(
"TypeOfProduct",
PumpDatasheet.Nameplate.TypeOfProduct)
.WithProperty("Supplier", PumpDatasheet.Nameplate.Supplier)
.WithProperty(
"CountryOfOrigin",
PumpDatasheet.Nameplate.CountryOfOrigin)
.WithProperty("FabricationNumber", fabricationNumber);
}
private void WithMaintenance(INodeManagerBuilder builder, PumpState pump)
{
var functionalGroupType = NodeId.Create(
Opc.Ua.Di.ObjectTypes.FunctionalGroupType,
Opc.Ua.Di.Namespaces.OpcUaDi,
Server.NamespaceUris);
var generalMaintenance = new QualifiedName(
"GeneralMaintenance",
(ushort)Server.NamespaceUris.GetIndex(Opc.Ua.Pumps.Namespaces.Pumps));
builder.Node(pump.Maintenance!.NodeId)
.AddObject(generalMaintenance, functionalGroupType)
.WithProperty(
"MaintenancePlan",
Variant.From("Inspect seals and bearings every 30 days."));
}
private PumpSimulationState CreatePumpSimulation(
INodeManagerBuilder builder,
PumpState pump,
int profileIndex)
{
MeasurementsState measurements = pump.Operational!.Measurements!;
AddMeasurement(
builder,
measurements.DifferentialPressure!.NodeId,
EngineeringUnits.Pascal,
min: PumpDatasheet.Ranges.DifferentialPressureMin,
max: PumpDatasheet.Ranges.DifferentialPressureMax,
out IValueUpdater pressure);
AddMeasurement(
builder,
measurements.FluidTemperature!.NodeId,
EngineeringUnits.Kelvin,
min: PumpDatasheet.Ranges.FluidTemperatureMin,
max: PumpDatasheet.Ranges.FluidTemperatureMax,
out IValueUpdater fluidTemperature);
AddMeasurement(
builder,
measurements.BearingTemperature!.NodeId,
EngineeringUnits.Kelvin,
min: PumpDatasheet.Ranges.BearingTemperatureMin,
max: PumpDatasheet.Ranges.BearingTemperatureMax,
out IValueUpdater bearingTemperature);
AddMeasurement(
builder,
measurements.PumpPowerInput!.NodeId,
EngineeringUnits.Watt,
min: PumpDatasheet.Ranges.PumpPowerInputMin,
max: PumpDatasheet.Ranges.PumpPowerInputMax,
out IValueUpdater power);
AddMeasurement(
builder,
measurements.MassFlow!.NodeId,
EngineeringUnits.KilogramsPerSecond,
min: PumpDatasheet.Ranges.MassFlowMin,
max: PumpDatasheet.Ranges.MassFlowMax,
out IValueUpdater flow);
AddMeasurement(
builder,
measurements.PumpEfficiency!.NodeId,
EngineeringUnits.Percent,
min: PumpDatasheet.Ranges.PumpEfficiencyMin,
max: PumpDatasheet.Ranges.PumpEfficiencyMax,
out IValueUpdater efficiency);
AddMeasurement(
builder,
measurements.Level!.NodeId,
EngineeringUnits.Metre,
min: PumpDatasheet.Ranges.LevelMin,
max: PumpDatasheet.Ranges.LevelMax,
out IValueUpdater level);
builder.Variable(measurements.NumberOfStarts!.NodeId)
.Bind(out IValueUpdater numberOfStarts)
.Historize(historyAccessLevel: AccessLevels.HistoryRead);
ushort pumpsNs = (ushort)Server.NamespaceUris.GetIndex(
Opc.Ua.Pumps.Namespaces.Pumps);
INodeBuilder pumpBuilder = builder.Node(pump.NodeId);
INodeBuilder events = pumpBuilder.Components().Events();
// The alarm reports the bearing-temperature chain, so its
// source node is the measurement and its limits are the
// datasheet trip points.
IAlarmBuilder overTempAlarm = events
.CreateLimitAlarm(new QualifiedName("OverTempAlarm", pumpsNs))
.WithLimits(
highHigh: PumpDatasheet.TripPoints.BearingTemperatureHighHigh,
high: PumpDatasheet.TripPoints.BearingTemperatureHigh,
low: PumpDatasheet.TripPoints.BearingTemperatureLow,
lowLow: PumpDatasheet.TripPoints.BearingTemperatureLowLow)
.MonitorVariable(measurements.BearingTemperature)
.OnAcknowledge((ctx, c, eventId, comment) => ServiceResult.Good);
IVariableBuilder cavitationBuilder = events.Components()
.SupervisionProcessFluid()
.Components()
.Cavitation();
WireBoolean(
cavitationBuilder,
new LocalizedText("Cavitation"),
new LocalizedText("No cavitation"),
out IValueUpdater cavitation);
IVariableBuilder motorOverheatBuilder = events.Components()
.SupervisionPumpOperation()
.Components()
.MotorOverheat();
WireBoolean(
motorOverheatBuilder,
new LocalizedText("Motor overheat"),
new LocalizedText("No motor overheat"),
out IValueUpdater motorOverheat);
motorOverheatBuilder.ActivatesAlarm(overTempAlarm);
return new PumpSimulationState(
profileIndex,
SimulationInterval,
pressure,
fluidTemperature,
bearingTemperature,
power,
flow,
efficiency,
level,
numberOfStarts,
cavitation,
motorOverheat);
}
private static void AddMeasurement(
INodeManagerBuilder builder,
NodeId nodeId,
EUInformation units,
double min,
double max,
out IValueUpdater updater)
{
builder.Variable(nodeId)
.Bind(out updater)
.WithEngineeringUnits(units)
.WithEURange(min, max)
.Historize(
historyAccessLevel: AccessLevels.HistoryRead,
autoCapture: true,
captureOptions: s_historianCaptureOptions);
}
private static void WireBoolean(
IVariableBuilder variable,
LocalizedText trueState,
LocalizedText falseState,
out IValueUpdater updater)
{
variable.Bind(out updater)
.Historize(historyAccessLevel: AccessLevels.HistoryRead);
BaseVariableState variableState = variable.Node;
NodeState? trueStateNode = variableState.FindChild(
variable.Builder.Context,
new QualifiedName("TrueState"));
if (trueStateNode is BaseVariableState trueStateVariable)
{
trueStateVariable.WrappedValue = Variant.From(trueState);
}
NodeState? falseStateNode = variableState.FindChild(
variable.Builder.Context,
new QualifiedName("FalseState"));
if (falseStateNode is BaseVariableState falseStateVariable)
{
falseStateVariable.WrappedValue = Variant.From(falseState);
}
}
private static readonly HistorianCaptureOptions s_historianCaptureOptions = new()
{
MaxQueuedSamples = 8192,
BatchTarget = 128,
BatchWindow = TimeSpan.FromMilliseconds(50),
FullMode = CaptureFullMode.DropOldest
};
private void AdvanceSimulation()
{
long tick = Interlocked.Increment(ref m_simulationTicks);
foreach (PumpSimulationState simulation in m_pumpSimulations.Values)
{
simulation.Advance(tick);
}
PublishOpenUsdSignals();
}
///
/// Adds a Variable to the set the simulation tick publishes.
///
/// The variable to publish every tick.
/// Yields the latest value.
///
/// The OpenUSD signals are created directly rather than through the
/// fluent builder, so they have no
/// of their own. A variable wired with only an OnRead handler
/// serves polled reads but never raises a data change, which leaves a
/// twin driven from a subscription rendering the start-up value
/// forever; publishing here is what makes monitored items observe them.
///
private void TrackSignal(BaseVariableState variable, Func getter)
{
lock (m_simulationRegistrationLock)
{
m_liveSignals = [.. m_liveSignals, (variable, getter)];
}
}
private void PublishOpenUsdSignals()
{
DateTime now = DateTime.UtcNow;
foreach ((BaseVariableState variable, Func getter) in
Volatile.Read(ref m_liveSignals))
{
variable.Value = getter();
variable.Timestamp = now;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
foreach (PumpTwin twin in m_twins.Values)
{
BaseDataVariableState? alarm = twin.AlarmActive;
if (alarm != null)
{
alarm.Value = twin.AlarmActiveState;
alarm.Timestamp = now;
alarm.ClearChangeMasks(SystemContext, includeChildren: false);
}
BaseDataVariableState? surface = twin.FluidSurface;
if (surface != null)
{
surface.Value = new ExtensionObject(
FluidSurfaceAt(twin.Simulation?.LevelMetres ??
PumpDatasheet.Simulation.LevelNominal));
surface.Timestamp = now;
surface.ClearChangeMasks(SystemContext, includeChildren: false);
}
}
}
private static class EngineeringUnits
{
public static readonly EUInformation Pascal =
new("Pa", "Pascal", "http://www.opcfoundation.org/UA/units/un/cefact");
public static readonly EUInformation Kelvin =
new("K", "Kelvin", "http://www.opcfoundation.org/UA/units/un/cefact");
public static readonly EUInformation Watt =
new("W", "Watt", "http://www.opcfoundation.org/UA/units/un/cefact");
public static readonly EUInformation KilogramsPerSecond =
new("kg/s", "Kilograms per Second", "http://www.opcfoundation.org/UA/units/un/cefact");
public static readonly EUInformation Percent =
new("%", "Percent", "http://www.opcfoundation.org/UA/units/un/cefact");
public static readonly EUInformation Metre =
new("m", "Metre", "http://www.opcfoundation.org/UA/units/un/cefact");
}
private sealed class PumpSimulationState
{
public PumpSimulationState(
int profileIndex,
TimeSpan tickInterval,
IValueUpdater pressure,
IValueUpdater fluidTemperature,
IValueUpdater bearingTemperature,
IValueUpdater power,
IValueUpdater flow,
IValueUpdater efficiency,
IValueUpdater level,
IValueUpdater numberOfStarts,
IValueUpdater cavitation,
IValueUpdater motorOverheat)
{
m_phaseOffset =
profileIndex * PumpDatasheet.Simulation.PhaseOffsetTicks;
m_tickInterval = tickInterval;
m_pressure = pressure;
m_fluidTemperature = fluidTemperature;
m_bearingTemperature = bearingTemperature;
m_power = power;
m_flow = flow;
m_efficiency = efficiency;
m_level = level;
m_numberOfStarts = numberOfStarts;
m_cavitation = cavitation;
m_motorOverheat = motorOverheat;
}
public void Initialize(long tick)
{
Publish(tick, publishAll: true, StatusCodes.BadWaitingForInitialData);
}
public void Advance(long tick)
{
Publish(tick, publishAll: !m_hasPublishedGoodValue, StatusCodes.Good);
m_hasPublishedGoodValue = true;
}
///
/// Publishes one simulation step. Volumetric flow is the only
/// independent variable; head, differential pressure, mass
/// flow, efficiency and shaft power all follow from the
/// datasheet characteristic curves, so the published values
/// stay mutually consistent.
///
private void Publish(long tick, bool publishAll, StatusCode statusCode)
{
long localTick = tick + m_phaseOffset;
DateTime sourceTimestamp = DateTime.UtcNow;
double flow = PumpDatasheet.Hydraulics.RatedFlow *
(1.0 +
(PumpDatasheet.Simulation.FlowModulation *
Math.Sin(localTick * PumpDatasheet.Simulation.FlowRate)));
double head = Head(flow);
double efficiency = Efficiency(flow);
double massFlow = PumpDatasheet.Hydraulics.FluidDensity *
flow /
3600.0;
double differentialPressure =
PumpDatasheet.Hydraulics.FluidDensity *
PumpDatasheet.Hydraulics.GravitationalAcceleration *
head;
double shaftPower = differentialPressure * (flow / 3600.0) /
(efficiency / 100.0);
double bearingTemperature =
PumpDatasheet.Simulation.BearingTemperatureBase +
(PumpDatasheet.Simulation.BearingTemperatureLoadRise *
shaftPower /
PumpDatasheet.Hydraulics.RatedShaftPower) +
CoolingFaultExcursion(localTick);
double level = PumpDatasheet.Simulation.LevelNominal +
(PumpDatasheet.Simulation.LevelAmplitude *
Math.Sin(localTick * PumpDatasheet.Simulation.LevelRate));
Volatile.Write(ref m_levelMetres, level);
m_pressure.SetValue(differentialPressure, statusCode, sourceTimestamp);
m_flow.SetValue(massFlow, statusCode, sourceTimestamp);
m_efficiency.SetValue(efficiency, statusCode, sourceTimestamp);
m_power.SetValue(shaftPower, statusCode, sourceTimestamp);
m_bearingTemperature.SetValue(
bearingTemperature,
statusCode,
sourceTimestamp);
m_level.SetValue(level, statusCode, sourceTimestamp);
m_fluidTemperature.SetValue(
PumpDatasheet.Simulation.FluidTemperatureNominal +
(PumpDatasheet.Simulation.FluidTemperatureAmplitude *
Math.Sin(
localTick *
PumpDatasheet.Simulation.FluidTemperatureRate)),
statusCode,
sourceTimestamp);
// Integrate the shaft position from the running speed so the
// twin shows the pump actually turning. Speed follows flow, so
// the impeller visibly slows and picks up with the duty point.
// MassFlow is a rate: binding it straight to a rotation op
// would pin the shaft at a fraction of a degree instead.
// Unbounded on purpose - wrapping at 360 would make a client
// that interpolates between samples spin the shaft backwards
// across the wrap.
double rpm = PumpDatasheet.Hydraulics.RatedSpeed *
(massFlow / PumpDatasheet.Hydraulics.RatedMassFlow);
double seconds = m_tickInterval.TotalSeconds;
Volatile.Write(
ref m_shaftAngle,
Volatile.Read(ref m_shaftAngle) + (rpm * 6.0 * seconds));
uint numberOfStarts = checked((uint)(localTick /
PumpDatasheet.Simulation.StartIntervalTicks));
if (publishAll || numberOfStarts != m_currentNumberOfStarts)
{
m_currentNumberOfStarts = numberOfStarts;
m_numberOfStarts.SetValue(numberOfStarts, statusCode, sourceTimestamp);
}
// Cavitation is reported once the suction head falls below
// the NPSH requirement, with hysteresis so the supervision
// state does not chatter at the threshold.
bool cavitation = m_currentCavitation
? level < PumpDatasheet.TripPoints.CavitationClearLevel
: level < PumpDatasheet.TripPoints.CavitationSetLevel;
if (publishAll || cavitation != m_currentCavitation)
{
m_currentCavitation = cavitation;
m_cavitation.SetValue(cavitation, statusCode, sourceTimestamp);
}
bool motorOverheat = m_currentMotorOverheat
? bearingTemperature >=
PumpDatasheet.TripPoints.MotorOverheatClear
: bearingTemperature >=
PumpDatasheet.TripPoints.MotorOverheatSet;
if (publishAll || motorOverheat != m_currentMotorOverheat)
{
m_currentMotorOverheat = motorOverheat;
m_motorOverheat.SetValue(motorOverheat, statusCode, sourceTimestamp);
}
}
///
/// Head curve of the datasheet, in m for a flow in m³/h.
///
private static double Head(double flow)
{
return PumpDatasheet.Hydraulics.ShutoffHead -
(PumpDatasheet.Hydraulics.HeadCurveCoefficient * flow * flow);
}
///
/// Efficiency curve of the datasheet, in percent for a flow in
/// m³/h.
///
private static double Efficiency(double flow)
{
double deviation =
(flow - PumpDatasheet.Hydraulics.RatedFlow) /
PumpDatasheet.Hydraulics.RatedFlow;
return PumpDatasheet.Hydraulics.RatedEfficiency *
(1.0 -
(PumpDatasheet.Hydraulics.EfficiencyCurveFactor *
deviation *
deviation));
}
///
/// Bearing temperature rise in K caused by the periodic
/// bearing-cooling interruption documented in the datasheet.
/// The ramp crosses both the high and the high-high trip point
/// so a complete alarm cycle is observable.
///
private static double CoolingFaultExcursion(long tick)
{
long cycle = tick % PumpDatasheet.Simulation.CoolingFaultPeriodTicks;
if (cycle < PumpDatasheet.Simulation.CoolingFaultOnsetTick)
{
return 0.0;
}
const long rampTicks = PumpDatasheet.Simulation.CoolingFaultPeriodTicks -
PumpDatasheet.Simulation.CoolingFaultOnsetTick;
return PumpDatasheet.Simulation.CoolingFaultRise *
(cycle - PumpDatasheet.Simulation.CoolingFaultOnsetTick) /
rampTicks;
}
///
/// Shaft angular position in degrees, integrated from the running
/// speed. Drives the OpenUSD rotation binding.
///
public double ShaftAngleDegrees => Volatile.Read(ref m_shaftAngle);
///
/// The supervision state the OpenUSD status-light binding follows.
///
public bool AlarmActive => m_currentCavitation || m_currentMotorOverheat;
///
/// Suction vessel level in metres, as last published. Drives the
/// OpenUSD liquid-surface binding.
///
public double LevelMetres => Volatile.Read(ref m_levelMetres);
private readonly long m_phaseOffset;
private readonly TimeSpan m_tickInterval;
private readonly IValueUpdater m_pressure;
private readonly IValueUpdater m_fluidTemperature;
private readonly IValueUpdater m_bearingTemperature;
private readonly IValueUpdater m_power;
private readonly IValueUpdater m_flow;
private readonly IValueUpdater m_efficiency;
private readonly IValueUpdater m_level;
private readonly IValueUpdater m_numberOfStarts;
private readonly IValueUpdater m_cavitation;
private readonly IValueUpdater m_motorOverheat;
private uint m_currentNumberOfStarts;
private bool m_currentCavitation;
private bool m_currentMotorOverheat;
private double m_shaftAngle;
private double m_levelMetres;
private bool m_hasPublishedGoodValue;
}
private readonly ConcurrentDictionary m_pumpSimulations = new();
private readonly Lock m_simulationRegistrationLock = new();
private long m_simulationTicks;
private int m_nextSimulationProfile;
///
/// Measurement Variables created outside the fluent builder, paired
/// with the getter that yields their latest simulated value.
///
///
/// Held as an immutable snapshot that replaces
/// under . A pump can be added
/// through CreatePumpAsync while the simulation timer is running,
/// and mutating a list while the tick enumerates it would throw on the
/// timer thread and stop the simulation for every pump. Publishing from a
/// snapshot keeps the tick allocation-free and lock-free.
///
private (BaseVariableState Variable, Func Getter)[] m_liveSignals = [];
}
///
/// Defines log messages for configuring the pump node manager.
///
internal static partial class PumpNodeManagerLog
{
///
/// Logs the start of fluent pump node manager configuration.
///
[LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.PumpNodeManager + 0,
Level = LogLevel.Information,
Message = "Configuring PumpNodeManager fluent wiring...")]
public static partial void ConfiguringPumpNodeManagerFluentWiring(this ILogger logger);
}
}