/* ========================================================================
* 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);
}
}
}