/* ======================================================================== * 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 { /// /// The datasheet-driven simulation, the operating state machine and the /// protection alarms. /// public partial class GeneratorNodeManager { private readonly ConcurrentDictionary m_simulations = new(); private readonly Lock m_registrationLock = new(); /// /// Serialises the simulation tick against client method calls. /// /// /// 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. /// private readonly Lock m_simulationGate = new(); private long m_ticks; private int m_nextProfile; private GeneratorSimulation? m_faultSubject; /// /// Seconds between the start of one fault cycle and the next. /// private const double FaultCycleSeconds = 150.0; /// /// Seconds a fault is left in place, long enough to trip and annunciate. /// private const double FaultDwellSeconds = 20.0; /// /// The faults the schedule rotates through, one per cycle, so a long run /// exercises every protection rather than the same one repeatedly. /// private static readonly GeneratorFault[] s_faultRotation = [ GeneratorFault.CoolingFailure, GeneratorFault.OilPressureLoss, GeneratorFault.Overload, GeneratorFault.GovernorFailure, ]; /// /// Wires the simulation for every materialised set and starts the tick. /// /// The active fluent builder. partial void Configure(INodeManagerBuilder builder) { foreach (GeneratorSetState set in m_generatorSets) { RegisterGeneratorSimulation(builder, set); } builder.Simulation(SimulationInterval) .OnTick((ctx, elapsed) => AdvanceSimulation()); } /// /// Wires a set created after the initial fluent configuration into the /// already-running simulation. /// /// The registered generator set. /// Cancellation token. 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); } /// /// Binds every measured variable of one set and records its simulation state. /// /// The active fluent builder. /// The generator set to wire. 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 speed); Analog(builder, engine.OilPressure!.NodeId, EngineeringUnits.Pascal, 0, GeneratorDatasheet.Convert.ToPascal(GeneratorDatasheet.Ranges.OilPressureMaxBar), out IValueUpdater oilPressure); Analog(builder, engine.CoolantTemperature!.NodeId, EngineeringUnits.Kelvin, GeneratorDatasheet.Convert.ToKelvin(0), GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.CoolantMaxCelsius), out IValueUpdater coolantTemperature); Analog(builder, engine.ExhaustGasTemperature!.NodeId, EngineeringUnits.Kelvin, GeneratorDatasheet.Convert.ToKelvin(0), GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.ExhaustMaxCelsius), out IValueUpdater exhaustTemperature); Analog(builder, engine.FuelRate!.NodeId, EngineeringUnits.CubicMetrePerSecond, 0, GeneratorDatasheet.Convert.ToCubicMetresPerSecond( GeneratorDatasheet.Ranges.FuelRateMaxLitresPerHour), out IValueUpdater fuelRate); Analog(builder, engine.EngineHours!.NodeId, EngineeringUnits.Hour, 0, 100_000, out IValueUpdater engineHours); Analog(builder, engine.PercentLoad!.NodeId, EngineeringUnits.Percent, 0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater percentLoad); builder.Variable(engine.NumberOfStarts!.NodeId) .Bind(out IValueUpdater starts); // Alternator aggregates. Analog(builder, alternator.Frequency!.NodeId, EngineeringUnits.Hertz, GeneratorDatasheet.Ranges.FrequencyMinHertz, GeneratorDatasheet.Ranges.FrequencyMaxHertz, out IValueUpdater frequency); Analog(builder, alternator.TotalRealPower!.NodeId, EngineeringUnits.Watt, 0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater realPower); Analog(builder, alternator.TotalApparentPower!.NodeId, EngineeringUnits.VoltAmpere, 0, GeneratorDatasheet.Ranges.RealPowerMaxWatts / GeneratorDatasheet.Electrical.RatedPowerFactor, out IValueUpdater apparentPower); Analog(builder, alternator.AverageLineToLineVoltage!.NodeId, EngineeringUnits.Volt, 0, GeneratorDatasheet.Ranges.VoltageMaxVolts, out IValueUpdater voltage); Analog(builder, alternator.AverageCurrent!.NodeId, EngineeringUnits.Ampere, 0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater current); Analog(builder, alternator.LoadPercent!.NodeId, EngineeringUnits.Percent, 0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater loadPercent); Analog(builder, alternator.TotalRealEnergy!.NodeId, EngineeringUnits.WattHour, 0, 1e9, out IValueUpdater energy); builder.Variable(alternator.AveragePowerFactor!.NodeId) .Bind(out IValueUpdater 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 pv); Analog(builder, phase.Current!.NodeId, EngineeringUnits.Ampere, 0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater pi); Analog(builder, phase.RealPower!.NodeId, EngineeringUnits.Watt, 0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater pp); builder.Variable(phase.PowerFactor!.NodeId) .Bind(out IValueUpdater ppf); phases[i] = new PhaseUpdaters(pv, pi, pp, ppf); } // Balance of plant. Analog(builder, set.FuelSystem!.FuelLevel!.NodeId, EngineeringUnits.Percent, 0, 100, out IValueUpdater fuelLevel); Analog(builder, set.FuelSystem!.TotalFuelConsumed!.NodeId, EngineeringUnits.Litre, 0, 1e6, out IValueUpdater fuelConsumed); Analog(builder, set.CoolingSystem!.AmbientTemperature!.NodeId, EngineeringUnits.Kelvin, GeneratorDatasheet.Convert.ToKelvin(-40), GeneratorDatasheet.Convert.ToKelvin(60), out IValueUpdater ambient); Analog(builder, set.LubricationSystem!.OilTemperature!.NodeId, EngineeringUnits.Kelvin, GeneratorDatasheet.Convert.ToKelvin(0), GeneratorDatasheet.Convert.ToKelvin(150), out IValueUpdater oilTemperature); Analog(builder, set.StartingSystem!.BatteryVoltage!.NodeId, EngineeringUnits.Volt, 0, GeneratorDatasheet.Ranges.BatteryVoltageMaxVolts, out IValueUpdater batteryVoltage); builder.Variable(set.GeneratorBreakerClosed!.NodeId) .Bind(out IValueUpdater breakerClosed); builder.Variable(set.AvailableToLoad!.NodeId) .Bind(out IValueUpdater 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); } /// /// Lets one set develop a fault on a slow rotation, so the protection /// path is exercised in a running plant. /// /// Monotonic tick counter. /// /// /// 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, ResetFaults 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. /// /// /// Confined to a single set so the rest of the plant stays a clean /// reference, and disabled entirely with --faults false. /// /// 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); } } /// /// Advances every set by one tick. /// /// /// Runs on a thread-pool thread while client requests are served on their /// own threads, so it is serialised against the method handlers by /// . 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. /// 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(); } /// /// Binds an analog variable with its engineering units and range. /// /// The active fluent builder. /// The variable to bind. /// Engineering units published as a property. /// Lower bound of the engineering range. /// Upper bound of the engineering range. /// Receives the updater the simulation writes through. private static void Analog( INodeManagerBuilder builder, NodeId nodeId, EUInformation units, double min, double max, out IValueUpdater updater) { builder.Variable(nodeId) .Bind(out updater) .WithEngineeringUnits(units) .WithEURange(min, max); } /// /// The UNECE/CEFACT units this sample publishes. /// /// /// The companion specification types every measured value as /// AnalogUnitType carrying a machine-readable unit, so a client can /// discover the unit from the model alone rather than guessing from a name. /// 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); } } /// /// Source-generated log messages for the generator simulation. /// 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); } }