mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
ca89ac85a7
# Description Adds a **Generators companion-specification sample** alongside the existing pump sample, and a **`SiteCompositionServer`** that composes several device servers into one OpenUSD scene at a supervisory level — demonstrating that servers can be aggregated without mirroring their address spaces. Three things, in dependency order: ### 1. `GeneratorServer` A self-contained server realising the draft Generators spec (`http://opcfoundation.org/UA/Generators/`) end to end: N simulated generating sets, DI + Machinery integration, a twelve-state operating state machine, four protection alarms per set, six control methods, and a per-set OpenUSD twin. The organising idea is that **load fraction is the only independent variable**. Everything else is a function of it: ``` V̇_f(x) = 3.67 + 100·x fuel rate [L/h] η(x) = P(x) / (V̇_f(x) · ρ · LHV) efficiency S = P / PF I = S / (√3·V_LL) f = N·p / 120 ``` This is not stylistic. When each measurement is an independent oscillator — as the pump sample's simulation once was — a server happily publishes a duty point no real machine could occupy, and no test can catch it because there is nothing for the values to be inconsistent *with*. Deriving them from one variable means `P = √3·V·I·PF` and `η = P/(V̇·ρ·LHV)` hold at every tick by construction. The published `DATASHEET.md`, the engineering ranges, the trip points and the simulation all read the same constants in `GeneratorDatasheet.cs`, so the document and the server cannot describe different machines. The machine is a fictitious **SimGen Systems GenX-500** (400 kW prime / 440 kW standby, 400/230 V, 50 Hz, four-pole). Figures are representative of a real industrial genset class; no vendor is named anywhere in the code or docs. ### 2. Cross-server composition in the connector `RemoteSessionFactory` already existed in the client library but the connector CLI never set it, so cross-server composition was unreachable. `--federate` wires it up: the connector opens a session to each server named by a component binding, discovers its representations and drives its bindings into the same stage. It is **opt-in** because the endpoint the connector dials comes from the server being rendered rather than from the operator, which makes honouring it a trust decision. Federation is best-effort per component — a subordinate that is down is logged and skipped, and the rest of the scene still renders — which is the only `src/` change in this PR. ### 3. `SiteCompositionServer` Owns no devices. It publishes a site stage plus one cross-server component binding per subordinate, carrying that server's `ComponentServerUri` and `ComponentEndpointUrl`. Nothing is mirrored, so there is no cache to invalidate and no second copy of the truth. ## Notes for reviewers **Everything interesting in this PR was found by running the servers, not by reading the code.** Six defects, none of which looked wrong in review: - `IsShutdown` and `SubsystemName` are *optional* on `GeneratorProtectionAlarmType`, so the generated factory does not materialise them. `CreateOrReplace` alone produces a child that exists, appears in `GetChildren` and holds the right value — but carries **no `ReferenceTypeId`**, so no browse can reach it. Every alarm was publishing its trip without saying whether the trip stops the machine or which subsystem to go to. `AddXxx(context)` first is what gives the child its `HasProperty` reference. - **The protections could never fire.** Every trip point sits outside the band the datasheet curves produce — that is what a datasheet *means* — so all four alarms, the shutdown class, `ResetFaults` and the whole `Fault` branch of the state machine were unreachable while the README documented them as observable. Overload was worse: the load clamp ceiling was set to exactly the trip point, making a strictly-greater-than comparison unsatisfiable. Faults are now injectable, and the first set — the one the hero camera frames — develops one on a slow rotation. - **A shutdown trip removes the condition that caused it**, because oil pressure and coolant temperature are only supervised while the engine turns. Each alarm went active for one tick and cleared, leaving an operator with a stopped machine and no indication of why. Shutdown-class alarms now latch until the set leaves the shutdown state. Every unit test that checked the *condition* passed throughout — the defect only appears when the trip and the supervision interact over time. - **`Synchronizing` and `Paralleled` were declared, drawn in the README and never entered.** The first set now energises a dead bus and closes onto it while every other set synchronises to a live one — both how a real plant parallels, and what makes the two states observable. - **The tick raced client method calls.** The simulation tick runs on a thread-pool thread while method calls arrive on request threads, and both transition sets and write the same nodes. A tick moving a set to `Cooldown` and a concurrent `EmergencyStop` could interleave the paired `CurrentState` / `CurrentState.Id` writes and leave a client with a state *name* from one transition and a state *node* from the other — the exact failure the paired write exists to prevent, reintroduced by the threading model. Both paths now take one gate. - Low oil pressure supervised from raw speed **tripped every set during cranking**, because pressure has not built yet. Now gated on `IsSpinning`, which is what a real start-up bypass does. Smaller fixes: shutdown trips are applied after the whole evaluation pass (stopping mid-loop made the remaining conditions read healthy, collapsing simultaneous trips to whichever came first in the table); `ResetFaults` reports its clears as events (a client learns of condition state changes only through events, so a silent clear leaves an alarm-list client showing it forever); the start counter moved into the transition so commanded starts are counted; the federated connector closes the remote session if the connector constructor throws between taking ownership and registering it; and `prepare_machinery_nodeset.py` now *asserts* the IA namespace is last before removing it rather than only claiming so in its docstring — removing any earlier entry would silently renumber every namespace after it and rebind their NodeIds. **A model limitation is left visible rather than papered over.** `GeneratorStateMachineType` declares an emergency stop only out of `Running`, `Loaded` and `Paralleled`, so the sample refuses `EmergencyStop` from `Starting` and `Warmup`. A real panel stops from anywhere. That is the specification's shape, not this sample's choice, so it is pinned by a test and named in the docs. **`Model/` vendors a reduced Machinery nodeset**, derived mechanically by whitelist. The full official nodeset does not survive the model source generator (`MODELGEN003`) and drags in IA through a single optional `Stacklight` member a generating set does not have. Deriving it by script keeps the provenance checkable; the whitelist is the only thing to edit when more types are needed. **Never hand-edit the generated output.** **The federated scene renders end to end.** Getting there surfaced three more defects, all of which produced a plausible-looking scene with nothing in it: the generator sample's plant aggregation was created under an already-registered `DeviceSet` and so was invisible to every client (it had *never* rendered geometry, standalone or federated); the connector's asset fetch ran on the primary session only, so composition referenced layers that were never downloaded; and both generator colour bindings wrote `primvars:displayColor` to prims that did not declare it, so the renderer rejected every update while the file still looked correct. Cross-server components compose under the subordinate's own root, so the site layer now *places* `/Plant` and `/Powerhouse` rather than leaving them stacked on the origin. **`OpenUsdRepresentation` is now mounted with `HasAddIn`, not `HasComponent`** (review feedback). The nodeset says so explicitly — *"Mounted with HasAddIn"* — but every server in the tree used plain `HasComponent`. It survived because `HasAddIn` is a *subtype* of `HasComponent`: the representation still browses, still aggregates and still drives a twin, so every functional test passed before the fix and after it, and only a conformance checker could tell. Seven mount sites corrected. `PumpDeviceIntegrationServer` and `MinimalRobotServer` had hand-rolled the mount instead of calling the shared `CreateRepresentation` helper, which is exactly how they drifted from it. Both sample E2E suites now assert the reference type across **every** discovered representation — a narrower check would have missed the plant-level representation that arrived later from master still carrying `HasComponent`. **Live colour is not achievable in the current OpenUSD viewer, and that is now documented rather than worked around.** Proven with a standalone probe: `primvars:displayColor` resolves as `color3f[]` from the `UsdGeomGprim` schema whatever the layer declares, and the `OpenUsd 0.4.0-alpha` managed API has no writer for that type. Bound `UsdPreviewSurface` materials are not shaded either, so geometry renders grey unless it also carries an explicit `displayColor` primvar. The **visibility** bindings carry no such caveat and are what the samples rely on to show state. Filed upstream as [openusd-dotnet#2](https://github.com/marcschier/openusd-dotnet/issues/2), [#3](https://github.com/marcschier/openusd-dotnet/issues/3) and [#4](https://github.com/marcschier/openusd-dotnet/issues/4), and linked from `docs/OpenUsd.md` so a reader can check whether they have since been fixed. ## Related Issues No tracking issue — this is additive sample and documentation work with a single small, self-contained change to `src/` (per-component error isolation and session-ownership hardening in `OpenUsdConnector.Composition.cs`). Happy to open one if maintainers would prefer it tracked. ## 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. - [x] 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. ### Testing The generator fixtures live in `tests/Opc.Ua.OpenUsd.Tests/Generator` — **82 tests**, no new project. `Opc.Ua.Di.Tests` would have been the natural home next to the pump fixtures, but both device samples source-generate their own reduced `Opc.Ua.Machinery` model and expose it via `InternalsVisibleTo`, so referencing both from one assembly makes every Machinery type ambiguous (CS0433); `Opc.Ua.OpenUsd.Tests` references no other sample and already hosts `RobotAssetContractTests`. The suite runs **870** on net10.0 and net48 (784 existing + 86) and **788** on net472, where the sample does not exist and only the framework-independent asset-contract tests run — forgetting exactly that exclusion broke CI on the sibling pump PR. The tests target the things in this sample that fail *quietly*: - **Datasheet conformance** holds the model to its own claim — rated speed is `120·f/p`, rated current follows from the rating, the fuel curve reproduces the published table, and `η = P/(V̇·ρ·LHV)` reconciles at every load. Efficiency is swept across the whole simulated range to show it stays inside (0,100) and rises monotonically; a fit that goes negative somewhere in its range is a coincidence that happens to look right at the duty point, not a model. A further test reads `DATASHEET.md` and checks the document quotes the figures the server actually serves. - **Drift between the two descriptions of the state machine** — the physics' `IsLegalTransition` and the model's `GeneratorStateMap` — is checked in *both* directions. A transition the physics permits but the map lacks moves a machine without telling a client; one the map holds but the physics refuses is dead weight that looks supported. - **Reachability at run time**, not just in the declared table — the test that catches states the model declares and nothing ever enters. - **That every protection can actually fire**, which is the half of the alarm contract that "a healthy set annunciates nothing" does not cover. A **hosted end-to-end fixture** connects a real client and asserts what the address space actually exposes — the aggregation at `/Powerhouse/Generators`, its component binding, and every per-set twin. That is the only kind of test that catches the registration defect above: the node object had the right browse name, binding and asset reference, so every test that inspected objects passed while no client could see it. **Every state-machine and protection fix was checked by mutation.** Restoring the clamp ceiling, the direct `Running → Loaded` path, the old start-counter placement, or removing the alarm opt-in each makes the corresponding test fail, naming the specific defect. A test that has never been seen to fail has not been shown to work. Verified against a running plant: `Synchronizing`, `Paralleled` and `Fault` all observed on a two-set server, with `HighCoolantTemperatureAlarm` seen active on the fault subject. Method behaviour verified separately — per-set distinct `CurrentState` nodes, `EmergencyStop` → `EmergencyStopped` with `Start` then refused as `BadInvalidState`, `ResetFaults` → `Off`, `SetOperatingMode(9999)` refused with the mode unchanged, and the other sets untouched throughout. Also verified as a three-server federated stage. 0 warnings on a clean Release rebuild of both samples, the client library, the connector and the tests, across every TFM. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fe608993-f3c9-4779-a6b9-9a9eabfc24ba
718 lines
31 KiB
C#
718 lines
31 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 Opc.Ua.Server.Fluent;
|
|
|
|
namespace Generators
|
|
{
|
|
/// <summary>
|
|
/// Small numeric helpers that are not available on every target framework.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>Math.Clamp</c> 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.
|
|
/// </remarks>
|
|
internal static class Numeric
|
|
{
|
|
/// <summary>
|
|
/// Clamps a value to an inclusive range.
|
|
/// </summary>
|
|
/// <param name="value">The value to clamp.</param>
|
|
/// <param name="min">Lower bound.</param>
|
|
/// <param name="max">Upper bound.</param>
|
|
/// <returns>The value, bounded by <paramref name="min"/> and <paramref name="max"/>.</returns>
|
|
public static double Clamp(double value, double min, double max)
|
|
{
|
|
if (value < min)
|
|
{
|
|
return min;
|
|
}
|
|
return value > max ? max : value;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The twelve operating states of a generating set.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Mirrors <c>GeneratorStateMachineType</c>. Kept as a plain enum so the
|
|
/// simulation can reason about the state without touching address-space nodes
|
|
/// on the tick path.
|
|
/// </remarks>
|
|
internal enum GeneratorRunState
|
|
{
|
|
/// <summary>Shut down and not available.</summary>
|
|
Off,
|
|
|
|
/// <summary>Available and waiting for a start command.</summary>
|
|
Ready,
|
|
|
|
/// <summary>Cranking.</summary>
|
|
Starting,
|
|
|
|
/// <summary>Running unloaded while coolant comes up to temperature.</summary>
|
|
Warmup,
|
|
|
|
/// <summary>At rated speed, breaker open.</summary>
|
|
Running,
|
|
|
|
/// <summary>Breaker closed and carrying load.</summary>
|
|
Loaded,
|
|
|
|
/// <summary>Matching voltage, frequency and phase to a live bus.</summary>
|
|
Synchronizing,
|
|
|
|
/// <summary>Sharing load with other sets on a common bus.</summary>
|
|
Paralleled,
|
|
|
|
/// <summary>Running unloaded to cool down before shutdown.</summary>
|
|
Cooldown,
|
|
|
|
/// <summary>Coming to rest.</summary>
|
|
Stopping,
|
|
|
|
/// <summary>Shut down by a protection trip.</summary>
|
|
Fault,
|
|
|
|
/// <summary>Shut down by the emergency stop.</summary>
|
|
EmergencyStopped,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Value updaters for the engine measurements.
|
|
/// </summary>
|
|
/// <param name="Speed">Engine speed, in revolutions per minute.</param>
|
|
/// <param name="OilPressure">Oil pressure, in pascal.</param>
|
|
/// <param name="CoolantTemperature">Coolant temperature, in kelvin.</param>
|
|
/// <param name="ExhaustTemperature">Exhaust gas temperature, in kelvin.</param>
|
|
/// <param name="FuelRate">Fuel rate, in cubic metres per second.</param>
|
|
/// <param name="EngineHours">Accumulated running hours.</param>
|
|
/// <param name="PercentLoad">Engine load, in percent.</param>
|
|
/// <param name="Starts">Number of start attempts.</param>
|
|
internal sealed record EngineUpdaters(
|
|
IValueUpdater<double> Speed,
|
|
IValueUpdater<double> OilPressure,
|
|
IValueUpdater<double> CoolantTemperature,
|
|
IValueUpdater<double> ExhaustTemperature,
|
|
IValueUpdater<double> FuelRate,
|
|
IValueUpdater<double> EngineHours,
|
|
IValueUpdater<double> PercentLoad,
|
|
IValueUpdater<uint> Starts);
|
|
|
|
/// <summary>
|
|
/// Value updaters for one alternator phase.
|
|
/// </summary>
|
|
/// <param name="Voltage">Line-to-neutral voltage, in volts.</param>
|
|
/// <param name="Current">Phase current, in amperes.</param>
|
|
/// <param name="RealPower">Phase real power, in watts.</param>
|
|
/// <param name="PowerFactor">Phase power factor.</param>
|
|
internal sealed record PhaseUpdaters(
|
|
IValueUpdater<double> Voltage,
|
|
IValueUpdater<double> Current,
|
|
IValueUpdater<double> RealPower,
|
|
IValueUpdater<double> PowerFactor);
|
|
|
|
/// <summary>
|
|
/// Value updaters for the alternator aggregates.
|
|
/// </summary>
|
|
/// <param name="Frequency">Output frequency, in hertz.</param>
|
|
/// <param name="RealPower">Total real power, in watts.</param>
|
|
/// <param name="ApparentPower">Total apparent power, in volt-amperes.</param>
|
|
/// <param name="Voltage">Average line-to-line voltage, in volts.</param>
|
|
/// <param name="Current">Average line current, in amperes.</param>
|
|
/// <param name="LoadPercent">Load, in percent of prime rating.</param>
|
|
/// <param name="Energy">Accumulated real energy, in watt-hours.</param>
|
|
/// <param name="PowerFactor">Average power factor.</param>
|
|
/// <param name="Phases">Per-phase updaters for L1, L2 and L3.</param>
|
|
internal sealed record AlternatorUpdaters(
|
|
IValueUpdater<double> Frequency,
|
|
IValueUpdater<double> RealPower,
|
|
IValueUpdater<double> ApparentPower,
|
|
IValueUpdater<double> Voltage,
|
|
IValueUpdater<double> Current,
|
|
IValueUpdater<double> LoadPercent,
|
|
IValueUpdater<double> Energy,
|
|
IValueUpdater<double> PowerFactor,
|
|
PhaseUpdaters[] Phases);
|
|
|
|
/// <summary>
|
|
/// Value updaters for the balance-of-plant subsystems.
|
|
/// </summary>
|
|
/// <param name="FuelLevel">Tank level, in percent.</param>
|
|
/// <param name="FuelConsumed">Total fuel consumed, in litres.</param>
|
|
/// <param name="AmbientTemperature">Ambient air temperature, in kelvin.</param>
|
|
/// <param name="OilTemperature">Oil temperature, in kelvin.</param>
|
|
/// <param name="BatteryVoltage">Starting battery voltage, in volts.</param>
|
|
/// <param name="BreakerClosed">Whether the generator breaker is closed.</param>
|
|
/// <param name="AvailableToLoad">Whether the set is ready to accept load.</param>
|
|
internal sealed record PlantUpdaters(
|
|
IValueUpdater<double> FuelLevel,
|
|
IValueUpdater<double> FuelConsumed,
|
|
IValueUpdater<double> AmbientTemperature,
|
|
IValueUpdater<double> OilTemperature,
|
|
IValueUpdater<double> BatteryVoltage,
|
|
IValueUpdater<bool> BreakerClosed,
|
|
IValueUpdater<bool> AvailableToLoad);
|
|
|
|
/// <summary>
|
|
/// A fault a generating set can develop, past the point where a protection
|
|
/// supervising it must annunciate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The healthy physics in <see cref="GeneratorDatasheet"/> 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 <em>after</em> the healthy value is derived, which is what a
|
|
/// real fault is: the machine no longer follows its curve.
|
|
/// </remarks>
|
|
internal enum GeneratorFault
|
|
{
|
|
/// <summary>Running to the datasheet.</summary>
|
|
None,
|
|
|
|
/// <summary>Radiator or fan failure: jacket water climbs past its limit.</summary>
|
|
CoolingFailure,
|
|
|
|
/// <summary>Pump or leak: oil pressure collapses while the engine turns.</summary>
|
|
OilPressureLoss,
|
|
|
|
/// <summary>The set is asked for more than its rating.</summary>
|
|
Overload,
|
|
|
|
/// <summary>Governor failure: the engine runs away.</summary>
|
|
GovernorFailure,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One generating set's simulated behaviour.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Load fraction is the only independent variable. Every published value
|
|
/// follows from it through the curves in <see cref="GeneratorDatasheet"/>, so
|
|
/// <c>P = sqrt(3) * V * I * PF</c> and
|
|
/// <c>eta = P / (Vdot * rho * LHV)</c> hold at every tick by construction
|
|
/// rather than by coincidence.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initialises the simulation for one set.
|
|
/// </summary>
|
|
/// <param name="profile">Zero-based per-set profile index.</param>
|
|
/// <param name="interval">Simulation tick interval.</param>
|
|
/// <param name="engine">Engine updaters.</param>
|
|
/// <param name="alternator">Alternator updaters.</param>
|
|
/// <param name="plant">Balance-of-plant updaters.</param>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the fault the set is currently running with.
|
|
/// </summary>
|
|
public GeneratorFault Fault => m_fault;
|
|
|
|
/// <summary>
|
|
/// Gives the set a fault, or clears one.
|
|
/// </summary>
|
|
/// <param name="fault">The fault to develop, or
|
|
/// <see cref="GeneratorFault.None"/> to run to the datasheet again.</param>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public void InjectFault(GeneratorFault fault)
|
|
{
|
|
m_fault = fault;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current operating state.
|
|
/// </summary>
|
|
public GeneratorRunState State => m_state;
|
|
|
|
/// <summary>
|
|
/// Gets the current load fraction, where 1.0 is prime power.
|
|
/// </summary>
|
|
public double LoadFraction { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the current engine speed, in revolutions per minute.
|
|
/// </summary>
|
|
public double SpeedRpm { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the current coolant temperature, in degrees Celsius.
|
|
/// </summary>
|
|
public double CoolantCelsius => m_coolantCelsius;
|
|
|
|
/// <summary>
|
|
/// Gets the current exhaust temperature, in degrees Celsius.
|
|
/// </summary>
|
|
public double ExhaustCelsius { get; private set; } =
|
|
GeneratorDatasheet.Simulation.AmbientCelsius;
|
|
|
|
/// <summary>
|
|
/// Gets the current fuel level, in percent of tank capacity.
|
|
/// </summary>
|
|
public double FuelLevelPercent => m_fuelLevelPercent;
|
|
|
|
/// <summary>
|
|
/// Gets the current oil pressure, in bar.
|
|
/// </summary>
|
|
public double OilPressureBar { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether a protection has tripped.
|
|
/// </summary>
|
|
public bool ProtectionTripped =>
|
|
m_state is GeneratorRunState.Fault or GeneratorRunState.EmergencyStopped;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the set is producing power.
|
|
/// </summary>
|
|
public bool IsLoaded =>
|
|
m_state is GeneratorRunState.Loaded or GeneratorRunState.Paralleled;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the coolant is above its trip point.
|
|
/// </summary>
|
|
public bool CoolantOverTemperature =>
|
|
IsSpinning && m_coolantCelsius > GeneratorDatasheet.TripPoints.HighCoolantCelsius;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether oil pressure is below its trip point.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public bool LowOilPressure =>
|
|
IsSpinning && OilPressureBar < GeneratorDatasheet.TripPoints.LowOilPressureBar;
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the engine is turning at rated speed.
|
|
/// </summary>
|
|
public bool IsSpinning =>
|
|
m_state is GeneratorRunState.Warmup
|
|
or GeneratorRunState.Running
|
|
or GeneratorRunState.Loaded
|
|
or GeneratorRunState.Synchronizing
|
|
or GeneratorRunState.Paralleled
|
|
or GeneratorRunState.Cooldown;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the callback invoked when the operating state changes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public Action<GeneratorRunState, GeneratorRunState>? StateChanged { get; set; }
|
|
|
|
/// <summary>
|
|
/// Requests a state change from outside the automatic cycle.
|
|
/// </summary>
|
|
/// <param name="state">The state to enter.</param>
|
|
/// <returns>
|
|
/// <see langword="true"/> when the request was accepted.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public bool RequestState(GeneratorRunState state)
|
|
{
|
|
if (!IsLegalTransition(m_state, state))
|
|
{
|
|
return false;
|
|
}
|
|
Enter(state);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether a transition between two states is declared by the model.
|
|
/// </summary>
|
|
/// <param name="from">Current state.</param>
|
|
/// <param name="to">Requested state.</param>
|
|
/// <returns><see langword="true"/> when the transition exists.</returns>
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trips the set on a protection, if it is running.
|
|
/// </summary>
|
|
/// <returns><see langword="true"/> when the set was tripped.</returns>
|
|
public bool Trip()
|
|
{
|
|
return RequestState(GeneratorRunState.Fault);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances the set by one tick and publishes every derived value.
|
|
/// </summary>
|
|
/// <param name="tick">Monotonic tick counter shared by all sets.</param>
|
|
public void Advance(long tick)
|
|
{
|
|
m_stateSeconds += m_tickSeconds;
|
|
AdvanceState();
|
|
double load = ComputeLoadFraction(tick);
|
|
LoadFraction = load;
|
|
Publish(load);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the operating state machine.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transitions to a new state and resets the dwell timer.
|
|
/// </summary>
|
|
/// <param name="state">The state to enter.</param>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the load fraction for the current state and tick.
|
|
/// </summary>
|
|
/// <param name="tick">Monotonic tick counter.</param>
|
|
/// <returns>Load as a fraction of prime power.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Derives and publishes every measured value from the load fraction.
|
|
/// </summary>
|
|
/// <param name="load">Load as a fraction of prime power.</param>
|
|
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);
|
|
}
|
|
}
|
|
}
|