/* ======================================================================== * 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 Opc.Ua.Server.Fluent; namespace Generators { /// /// Small numeric helpers that are not available on every target framework. /// /// /// Math.Clamp arrived after .NET Standard 2.0, and this sample builds for /// net48 as well, so the sample carries its own rather than gating the call /// sites behind conditional compilation. /// internal static class Numeric { /// /// Clamps a value to an inclusive range. /// /// The value to clamp. /// Lower bound. /// Upper bound. /// The value, bounded by and . public static double Clamp(double value, double min, double max) { if (value < min) { return min; } return value > max ? max : value; } } /// /// The twelve operating states of a generating set. /// /// /// Mirrors GeneratorStateMachineType. Kept as a plain enum so the /// simulation can reason about the state without touching address-space nodes /// on the tick path. /// internal enum GeneratorRunState { /// Shut down and not available. Off, /// Available and waiting for a start command. Ready, /// Cranking. Starting, /// Running unloaded while coolant comes up to temperature. Warmup, /// At rated speed, breaker open. Running, /// Breaker closed and carrying load. Loaded, /// Matching voltage, frequency and phase to a live bus. Synchronizing, /// Sharing load with other sets on a common bus. Paralleled, /// Running unloaded to cool down before shutdown. Cooldown, /// Coming to rest. Stopping, /// Shut down by a protection trip. Fault, /// Shut down by the emergency stop. EmergencyStopped, } /// /// Value updaters for the engine measurements. /// /// Engine speed, in revolutions per minute. /// Oil pressure, in pascal. /// Coolant temperature, in kelvin. /// Exhaust gas temperature, in kelvin. /// Fuel rate, in cubic metres per second. /// Accumulated running hours. /// Engine load, in percent. /// Number of start attempts. internal sealed record EngineUpdaters( IValueUpdater Speed, IValueUpdater OilPressure, IValueUpdater CoolantTemperature, IValueUpdater ExhaustTemperature, IValueUpdater FuelRate, IValueUpdater EngineHours, IValueUpdater PercentLoad, IValueUpdater Starts); /// /// Value updaters for one alternator phase. /// /// Line-to-neutral voltage, in volts. /// Phase current, in amperes. /// Phase real power, in watts. /// Phase power factor. internal sealed record PhaseUpdaters( IValueUpdater Voltage, IValueUpdater Current, IValueUpdater RealPower, IValueUpdater PowerFactor); /// /// Value updaters for the alternator aggregates. /// /// Output frequency, in hertz. /// Total real power, in watts. /// Total apparent power, in volt-amperes. /// Average line-to-line voltage, in volts. /// Average line current, in amperes. /// Load, in percent of prime rating. /// Accumulated real energy, in watt-hours. /// Average power factor. /// Per-phase updaters for L1, L2 and L3. internal sealed record AlternatorUpdaters( IValueUpdater Frequency, IValueUpdater RealPower, IValueUpdater ApparentPower, IValueUpdater Voltage, IValueUpdater Current, IValueUpdater LoadPercent, IValueUpdater Energy, IValueUpdater PowerFactor, PhaseUpdaters[] Phases); /// /// Value updaters for the balance-of-plant subsystems. /// /// Tank level, in percent. /// Total fuel consumed, in litres. /// Ambient air temperature, in kelvin. /// Oil temperature, in kelvin. /// Starting battery voltage, in volts. /// Whether the generator breaker is closed. /// Whether the set is ready to accept load. internal sealed record PlantUpdaters( IValueUpdater FuelLevel, IValueUpdater FuelConsumed, IValueUpdater AmbientTemperature, IValueUpdater OilTemperature, IValueUpdater BatteryVoltage, IValueUpdater BreakerClosed, IValueUpdater AvailableToLoad); /// /// A fault a generating set can develop, past the point where a protection /// supervising it must annunciate. /// /// /// The healthy physics in are functions of load /// fraction alone, and by construction they stay inside the trip points - a set /// running to its datasheet cannot protect-trip, which is the whole point of the /// datasheet. A fault is therefore modelled as a deviation applied to the /// measured value after the healthy value is derived, which is what a /// real fault is: the machine no longer follows its curve. /// internal enum GeneratorFault { /// Running to the datasheet. None, /// Radiator or fan failure: jacket water climbs past its limit. CoolingFailure, /// Pump or leak: oil pressure collapses while the engine turns. OilPressureLoss, /// The set is asked for more than its rating. Overload, /// Governor failure: the engine runs away. GovernorFailure, } /// /// One generating set's simulated behaviour. /// /// /// /// Load fraction is the only independent variable. Every published value /// follows from it through the curves in , so /// P = sqrt(3) * V * I * PF and /// eta = P / (Vdot * rho * LHV) hold at every tick by construction /// rather than by coincidence. /// /// /// Each set carries its own state, and its duty point and start-up schedule /// are offset by a per-set profile index, so no two sets in a plant report the /// same numbers or sit in the same operating state. /// /// internal sealed class GeneratorSimulation { private const double CrankSeconds = 4.0; private const double WarmupSeconds = 12.0; private const double CooldownSeconds = 10.0; private const double StoppingSeconds = 5.0; private readonly int m_profile; private readonly double m_tickSeconds; private readonly EngineUpdaters m_engine; private readonly AlternatorUpdaters m_alternator; private readonly PlantUpdaters m_plant; private GeneratorRunState m_state = GeneratorRunState.Off; private double m_stateSeconds; private double m_engineHours; private double m_energyWattHours; private double m_fuelConsumedLitres; private double m_fuelLevelPercent = GeneratorDatasheet.Simulation.InitialFuelPercent; private double m_coolantCelsius = GeneratorDatasheet.Simulation.AmbientCelsius; private uint m_starts; private GeneratorFault m_fault; /// /// Initialises the simulation for one set. /// /// Zero-based per-set profile index. /// Simulation tick interval. /// Engine updaters. /// Alternator updaters. /// Balance-of-plant updaters. public GeneratorSimulation( int profile, TimeSpan interval, EngineUpdaters engine, AlternatorUpdaters alternator, PlantUpdaters plant) { m_profile = profile; m_tickSeconds = interval.TotalSeconds; m_engine = engine; m_alternator = alternator; m_plant = plant; // Stagger the sets so a plant shows several states at once rather than // every machine marching in lockstep. m_stateSeconds = -profile * 6.0; } /// /// Gets the fault the set is currently running with. /// public GeneratorFault Fault => m_fault; /// /// Gives the set a fault, or clears one. /// /// The fault to develop, or /// to run to the datasheet again. /// /// Kept separate from the healthy curves so the datasheet identities still /// hold for a healthy set: a fault deviates the measured value from the /// curve rather than changing the curve. /// public void InjectFault(GeneratorFault fault) { m_fault = fault; } /// /// Gets the current operating state. /// public GeneratorRunState State => m_state; /// /// Gets the current load fraction, where 1.0 is prime power. /// public double LoadFraction { get; private set; } /// /// Gets the current engine speed, in revolutions per minute. /// public double SpeedRpm { get; private set; } /// /// Gets the current coolant temperature, in degrees Celsius. /// public double CoolantCelsius => m_coolantCelsius; /// /// Gets the current exhaust temperature, in degrees Celsius. /// public double ExhaustCelsius { get; private set; } = GeneratorDatasheet.Simulation.AmbientCelsius; /// /// Gets the current fuel level, in percent of tank capacity. /// public double FuelLevelPercent => m_fuelLevelPercent; /// /// Gets the current oil pressure, in bar. /// public double OilPressureBar { get; private set; } /// /// Gets a value indicating whether a protection has tripped. /// public bool ProtectionTripped => m_state is GeneratorRunState.Fault or GeneratorRunState.EmergencyStopped; /// /// Gets a value indicating whether the set is producing power. /// public bool IsLoaded => m_state is GeneratorRunState.Loaded or GeneratorRunState.Paralleled; /// /// Gets a value indicating whether the coolant is above its trip point. /// public bool CoolantOverTemperature => IsSpinning && m_coolantCelsius > GeneratorDatasheet.TripPoints.HighCoolantCelsius; /// /// Gets a value indicating whether oil pressure is below its trip point. /// /// /// Only supervised once the engine is turning at speed. A real set bypasses /// the low-oil-pressure trip while cranking, because oil pressure has not /// built yet - supervising it during start-up trips every set the moment it /// tries to start. /// public bool LowOilPressure => IsSpinning && OilPressureBar < GeneratorDatasheet.TripPoints.LowOilPressureBar; /// /// Gets a value indicating whether the engine is turning at rated speed. /// public bool IsSpinning => m_state is GeneratorRunState.Warmup or GeneratorRunState.Running or GeneratorRunState.Loaded or GeneratorRunState.Synchronizing or GeneratorRunState.Paralleled or GeneratorRunState.Cooldown; /// /// Gets or sets the callback invoked when the operating state changes. /// /// /// The enum below is the single decider of what state a set is in; the /// address-space state machine follows it through this callback. Having one /// place decide is what keeps the two from disagreeing - a state machine /// driven independently of the physics eventually reports a machine as /// running while the simulation says it is stopped. /// public Action? StateChanged { get; set; } /// /// Requests a state change from outside the automatic cycle. /// /// The state to enter. /// /// when the request was accepted. /// /// /// Used by the generator-set methods. Legality is checked here rather than /// in each handler so a caller cannot force a machine from Off straight to /// Loaded. /// public bool RequestState(GeneratorRunState state) { if (!IsLegalTransition(m_state, state)) { return false; } Enter(state); return true; } /// /// Returns whether a transition between two states is declared by the model. /// /// Current state. /// Requested state. /// when the transition exists. public static bool IsLegalTransition(GeneratorRunState from, GeneratorRunState to) { return (from, to) switch { (GeneratorRunState.Off, GeneratorRunState.Ready) => true, (GeneratorRunState.Ready, GeneratorRunState.Starting) => true, (GeneratorRunState.Ready, GeneratorRunState.Off) => true, (GeneratorRunState.Starting, GeneratorRunState.Warmup) => true, (GeneratorRunState.Starting, GeneratorRunState.Fault) => true, (GeneratorRunState.Warmup, GeneratorRunState.Running) => true, (GeneratorRunState.Running, GeneratorRunState.Loaded) => true, (GeneratorRunState.Running, GeneratorRunState.Synchronizing) => true, (GeneratorRunState.Running, GeneratorRunState.Cooldown) => true, (GeneratorRunState.Running, GeneratorRunState.Fault) => true, (GeneratorRunState.Running, GeneratorRunState.EmergencyStopped) => true, (GeneratorRunState.Synchronizing, GeneratorRunState.Paralleled) => true, (GeneratorRunState.Synchronizing, GeneratorRunState.Running) => true, (GeneratorRunState.Paralleled, GeneratorRunState.Loaded) => true, (GeneratorRunState.Paralleled, GeneratorRunState.Fault) => true, (GeneratorRunState.Paralleled, GeneratorRunState.EmergencyStopped) => true, (GeneratorRunState.Loaded, GeneratorRunState.Cooldown) => true, (GeneratorRunState.Loaded, GeneratorRunState.Fault) => true, (GeneratorRunState.Loaded, GeneratorRunState.EmergencyStopped) => true, (GeneratorRunState.Cooldown, GeneratorRunState.Stopping) => true, (GeneratorRunState.Stopping, GeneratorRunState.Off) => true, (GeneratorRunState.Fault, GeneratorRunState.Off) => true, (GeneratorRunState.EmergencyStopped, GeneratorRunState.Off) => true, _ => false, }; } /// /// Trips the set on a protection, if it is running. /// /// when the set was tripped. public bool Trip() { return RequestState(GeneratorRunState.Fault); } /// /// Advances the set by one tick and publishes every derived value. /// /// Monotonic tick counter shared by all sets. public void Advance(long tick) { m_stateSeconds += m_tickSeconds; AdvanceState(); double load = ComputeLoadFraction(tick); LoadFraction = load; Publish(load); } /// /// Runs the operating state machine. /// private void AdvanceState() { switch (m_state) { case GeneratorRunState.Off when m_stateSeconds > 3.0: Enter(GeneratorRunState.Ready); break; case GeneratorRunState.Ready when m_stateSeconds > 3.0: Enter(GeneratorRunState.Starting); break; case GeneratorRunState.Starting when m_stateSeconds > CrankSeconds: Enter(GeneratorRunState.Warmup); break; case GeneratorRunState.Warmup when m_stateSeconds > WarmupSeconds: Enter(GeneratorRunState.Running); break; // The first set energises a dead bus and simply closes onto it; // every other set has to match voltage, frequency and phase to a bus // that is already live before its breaker can close. Without this the // Synchronizing and Paralleled states, and the five transitions // through them, would be declared by the model and never observed. case GeneratorRunState.Running when m_stateSeconds > 6.0: Enter(m_profile == 0 ? GeneratorRunState.Loaded : GeneratorRunState.Synchronizing); break; case GeneratorRunState.Synchronizing when m_stateSeconds > 8.0: Enter(GeneratorRunState.Paralleled); break; case GeneratorRunState.Paralleled when m_stateSeconds > 25.0: Enter(GeneratorRunState.Loaded); break; case GeneratorRunState.Loaded when m_stateSeconds > 180.0: Enter(GeneratorRunState.Cooldown); break; case GeneratorRunState.Cooldown when m_stateSeconds > CooldownSeconds: Enter(GeneratorRunState.Stopping); break; case GeneratorRunState.Stopping when m_stateSeconds > StoppingSeconds: Enter(GeneratorRunState.Off); break; case GeneratorRunState.Fault when m_stateSeconds > 20.0: Enter(GeneratorRunState.Off); break; case GeneratorRunState.EmergencyStopped when m_stateSeconds > 30.0: Enter(GeneratorRunState.Off); break; default: break; } } /// /// Transitions to a new state and resets the dwell timer. /// /// The state to enter. private void Enter(GeneratorRunState state) { GeneratorRunState previous = m_state; m_state = state; m_stateSeconds = 0.0; if (previous != state) { // Counted here rather than in the automatic cycle, so a start // commanded through the Start or StartTest method is counted too. A // start counter that only sees half the starts is worse than none. if (state == GeneratorRunState.Starting) { m_starts++; } StateChanged?.Invoke(previous, state); } } /// /// Returns the load fraction for the current state and tick. /// /// Monotonic tick counter. /// Load as a fraction of prime power. private double ComputeLoadFraction(long tick) { if (!IsLoaded) { return 0.0; } double phase = (tick * m_tickSeconds / 45.0) + (m_profile * 0.9); double swing = Math.Sin(phase) * GeneratorDatasheet.Simulation.LoadSwingFraction; double load = GeneratorDatasheet.Simulation.NominalLoadFraction + swing; if (m_fault == GeneratorFault.Overload) { load = GeneratorDatasheet.TripPoints.OverloadFraction + 0.06; } // The ceiling is above the overload trip point rather than equal to it: // clamping to exactly the trip point makes a strictly-greater-than // comparison unsatisfiable, so the protection could never annunciate. return Numeric.Clamp(load, 0.05, GeneratorDatasheet.TripPoints.OverloadFraction + 0.10); } /// /// Derives and publishes every measured value from the load fraction. /// /// Load as a fraction of prime power. private void Publish(double load) { bool spinning = m_state is GeneratorRunState.Warmup or GeneratorRunState.Running or GeneratorRunState.Loaded or GeneratorRunState.Synchronizing or GeneratorRunState.Paralleled or GeneratorRunState.Cooldown; SpeedRpm = m_state switch { GeneratorRunState.Starting => GeneratorDatasheet.Engine.RatedSpeedRpm * 0.18, GeneratorRunState.Stopping => GeneratorDatasheet.Engine.RatedSpeedRpm * 0.35, _ when spinning => GeneratorDatasheet.Engine.RatedSpeedRpm, _ => 0.0, }; // Isochronous governor: rated speed with a small load-dependent droop // so the published frequency moves rather than sitting on a constant. double droop = spinning ? -load * 1.2 : 0.0; double speed = SpeedRpm + droop; double frequency = spinning ? speed * GeneratorDatasheet.Electrical.Poles / 120.0 : 0.0; double realPower = load * GeneratorDatasheet.Ratings.PrimePowerWatts; double powerFactor = GeneratorDatasheet.Electrical.RatedPowerFactor; double apparentPower = realPower / powerFactor; double lineVoltage = spinning ? GeneratorDatasheet.Electrical.RatedLineVoltage : 0.0; double current = lineVoltage > 0.0 ? apparentPower / (Math.Sqrt(3.0) * lineVoltage) : 0.0; // Thermal states chase their targets rather than jumping, so the twin // shows a machine warming up and cooling down. double coolantTarget = spinning ? GeneratorDatasheet.Curves.CoolantCelsius(load) : GeneratorDatasheet.Simulation.AmbientCelsius; m_coolantCelsius += (coolantTarget - m_coolantCelsius) * 0.02; double exhaustTarget = spinning ? GeneratorDatasheet.Curves.ExhaustCelsius(load) : GeneratorDatasheet.Simulation.AmbientCelsius; ExhaustCelsius += (exhaustTarget - ExhaustCelsius) * 0.05; OilPressureBar = spinning ? GeneratorDatasheet.Engine.RatedOilPressureBar * (0.85 + (0.15 * load)) : 0.0; // A fault deviates the measurement from the healthy curve, which is what // takes it past a trip point. Applied here rather than inside the curves // so the datasheet identities keep holding for a healthy set. switch (m_fault) { case GeneratorFault.CoolingFailure when spinning: m_coolantCelsius += (GeneratorDatasheet.TripPoints.HighCoolantCelsius + 8.0 - m_coolantCelsius) * 0.25; break; case GeneratorFault.OilPressureLoss when spinning: OilPressureBar = GeneratorDatasheet.TripPoints.LowOilPressureBar * 0.5; break; case GeneratorFault.GovernorFailure when spinning: SpeedRpm = GeneratorDatasheet.TripPoints.OverspeedRpm * 1.04; speed = SpeedRpm; frequency = speed * GeneratorDatasheet.Electrical.Poles / 120.0; break; default: break; } double fuelLitresPerHour = spinning ? GeneratorDatasheet.Curves.FuelLitresPerHour(load) : 0.0; if (spinning) { double hours = m_tickSeconds / GeneratorDatasheet.Convert.SecondsPerHour; m_engineHours += hours; m_energyWattHours += realPower * hours; double burned = fuelLitresPerHour * hours; m_fuelConsumedLitres += burned; m_fuelLevelPercent = Math.Max( 0.0, m_fuelLevelPercent - (100.0 * burned / GeneratorDatasheet.Fuel.TankCapacityLitres)); } m_engine.Speed.SetValue(speed); m_engine.OilPressure.SetValue(GeneratorDatasheet.Convert.ToPascal(OilPressureBar)); m_engine.CoolantTemperature.SetValue(GeneratorDatasheet.Convert.ToKelvin(m_coolantCelsius)); m_engine.ExhaustTemperature.SetValue(GeneratorDatasheet.Convert.ToKelvin(ExhaustCelsius)); m_engine.FuelRate.SetValue( GeneratorDatasheet.Convert.ToCubicMetresPerSecond(fuelLitresPerHour)); m_engine.EngineHours.SetValue(m_engineHours); m_engine.PercentLoad.SetValue(load * 100.0); m_engine.Starts.SetValue(m_starts); m_alternator.Frequency.SetValue(frequency); m_alternator.RealPower.SetValue(realPower); m_alternator.ApparentPower.SetValue(apparentPower); m_alternator.Voltage.SetValue(lineVoltage); m_alternator.Current.SetValue(current); m_alternator.LoadPercent.SetValue(load * 100.0); m_alternator.Energy.SetValue(m_energyWattHours); m_alternator.PowerFactor.SetValue(realPower > 0.0 ? powerFactor : 0.0); // A real machine is never perfectly balanced; a small fixed imbalance // per phase keeps L1/L2/L3 genuinely distinct without breaking the // aggregate identity, because the three offsets sum to zero. double[] imbalance = [1.004, 0.998, 0.998]; for (int i = 0; i < m_alternator.Phases.Length; i++) { PhaseUpdaters phase = m_alternator.Phases[i]; phase.Voltage.SetValue( spinning ? GeneratorDatasheet.Electrical.RatedPhaseVoltage * imbalance[i] : 0.0); phase.Current.SetValue(current * imbalance[i]); phase.RealPower.SetValue(realPower / 3.0 * imbalance[i]); phase.PowerFactor.SetValue(realPower > 0.0 ? powerFactor : 0.0); } m_plant.FuelLevel.SetValue(m_fuelLevelPercent); m_plant.FuelConsumed.SetValue(m_fuelConsumedLitres); m_plant.AmbientTemperature.SetValue( GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Simulation.AmbientCelsius)); m_plant.OilTemperature.SetValue( GeneratorDatasheet.Convert.ToKelvin(Math.Max( GeneratorDatasheet.Simulation.AmbientCelsius, m_coolantCelsius + 8.0))); m_plant.BatteryVoltage.SetValue( m_state == GeneratorRunState.Starting ? GeneratorDatasheet.Simulation.BatteryVolts * 0.82 : GeneratorDatasheet.Simulation.BatteryVolts + (spinning ? 3.6 : 0.0)); m_plant.BreakerClosed.SetValue(IsLoaded); m_plant.AvailableToLoad.SetValue( m_state is GeneratorRunState.Running or GeneratorRunState.Loaded); } } }