/* ======================================================================== * 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.Collections.Generic; using System.Linq; using Generators; using GeneratorModel = Opc.Ua.Generators; using NUnit.Framework; namespace Opc.Ua.OpenUsd.Tests.Generator { /// /// Holds the protection table to the datasheet and to the simulation. /// /// /// A protection that never trips is indistinguishable from a healthy machine, /// and a protection that trips on a threshold nobody published is worse: it /// stops a set for a reason no operator can look up. These tests hold both ends /// - the declared trip points and the conditions that read them. /// [TestFixture] [Category("Generators")] public sealed class GeneratorProtectionTests { /// /// The four protections the sample annunciates are all present, once each. /// [Test] public void EveryProtectionFunctionIsAnnunciatedExactlyOnce() { GeneratorModel.GeneratorProtectionFunctionEnum[] functions = Definitions() .Select(d => d.Function) .ToArray(); Assert.Multiple(() => { Assert.That(functions, Is.EquivalentTo(new[] { GeneratorModel.GeneratorProtectionFunctionEnum.LowOilPressure, GeneratorModel.GeneratorProtectionFunctionEnum.HighCoolantTemperature, GeneratorModel.GeneratorProtectionFunctionEnum.Overspeed, GeneratorModel.GeneratorProtectionFunctionEnum.Overload, })); Assert.That(functions.Distinct().Count(), Is.EqualTo(functions.Length)); }); } /// /// Alarm browse names are unique, since they mint the alarm NodeIds. /// [Test] public void AlarmNamesAreDistinct() { string[] names = Definitions().Select(d => d.Name).ToArray(); Assert.That(names.Distinct().Count(), Is.EqualTo(names.Length)); } /// /// Every protection names the subsystem an operator would go to. /// [Test] public void EveryProtectionNamesItsSubsystem() { foreach (ProtectionDefinition definition in Definitions()) { Assert.That( definition.Subsystem, Is.Not.Null.And.Not.Empty, $"{definition.Name} names no subsystem."); } } /// /// The three protections that stop an engine are marked as shutdowns; the /// one that does not, is not. /// /// /// This flag is what decides whether a trip stops the machine, so getting it /// wrong either strands a healthy set or keeps a damaged one loaded. /// [TestCase("LowOilPressureAlarm", true)] [TestCase("HighCoolantTemperatureAlarm", true)] [TestCase("OverspeedAlarm", true)] [TestCase("OverloadAlarm", false)] public void ShutdownClassMatchesTheProtection(string name, bool isShutdown) { ProtectionDefinition definition = Definitions().Single(d => d.Name == name); Assert.That(definition.IsShutdown, Is.EqualTo(isShutdown)); } /// /// A shutdown-class protection is never less severe than a warning. /// [Test] public void ShutdownProtectionsOutrankWarnings() { ushort worstWarning = Definitions() .Where(d => !d.IsShutdown) .Select(d => d.Severity) .DefaultIfEmpty((ushort)0) .Max(); foreach (ProtectionDefinition definition in Definitions().Where(d => d.IsShutdown)) { Assert.That( definition.Severity, Is.GreaterThan(worstWarning), $"{definition.Name} is a shutdown but ranks no higher than a warning."); } } /// /// A healthy set at its duty point annunciates nothing. /// /// /// The one failure mode that would make the sample useless: alarms that are /// on all the time teach an operator to ignore them. /// [Test] public void AHealthySetAnnunciatesNothing() { GeneratorSimulation simulation = SimulationHarness.Create(); Assert.That( SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded), Is.True); for (int tick = 0; tick < 400; tick++) { simulation.Advance(tick); foreach (ProtectionDefinition definition in Definitions()) { Assert.That( definition.IsTripped(simulation), Is.False, $"{definition.Name} tripped on a healthy set at tick {tick}."); } } } /// /// Low oil pressure is not supervised while the engine is cranking. /// /// /// Oil pressure has not built during a start, so supervising it there trips /// every set the moment it tries to run. A real set bypasses the trip for /// exactly this reason; this test pins that behaviour, because it was a real /// defect in this sample before the bypass was added. /// [Test] public void LowOilPressureIsBypassedWhileCranking() { GeneratorSimulation simulation = SimulationHarness.Create(); ProtectionDefinition lowOil = Definitions().Single(d => d.Name == "LowOilPressureAlarm"); simulation.RequestState(GeneratorRunState.Ready); simulation.RequestState(GeneratorRunState.Starting); for (int tick = 0; tick < 20 && simulation.State == GeneratorRunState.Starting; tick++) { simulation.Advance(tick); Assert.That( lowOil.IsTripped(simulation), Is.False, $"Low oil pressure tripped while cranking at {simulation.OilPressureBar:F2} bar."); } } /// /// A stopped set does not annunciate low oil pressure or over-temperature. /// [Test] public void AStoppedSetAnnunciatesNothing() { GeneratorSimulation simulation = SimulationHarness.Create(); Assert.Multiple(() => { Assert.That(simulation.LowOilPressure, Is.False); Assert.That(simulation.CoolantOverTemperature, Is.False); Assert.That(simulation.IsSpinning, Is.False); }); } /// /// The overspeed condition reads the datasheet's trip point. /// [Test] public void OverspeedTripsAboveTheDatasheetPoint() { Assert.Multiple(() => { Assert.That( GeneratorDatasheet.TripPoints.OverspeedRpm, Is.GreaterThan(GeneratorDatasheet.Engine.RatedSpeedRpm)); Assert.That( GeneratorDatasheet.TripPoints.OverspeedRpm, Is.EqualTo(1725.0).Within(0.001)); }); } /// /// The overload condition trips above the rating, not at it. /// [Test] public void OverloadTripsAboveThePrimeRating() { Assert.That(GeneratorDatasheet.TripPoints.OverloadFraction, Is.GreaterThan(1.0)); } /// /// Trip points sit outside the range a healthy set operates in. /// /// /// A trip point inside the normal band would fire on a machine that is /// working correctly, which is how a plant ends up with its protections /// disabled. /// [Test] public void TripPointsSitOutsideTheNormalOperatingBand() { Assert.Multiple(() => { Assert.That( GeneratorDatasheet.TripPoints.LowOilPressureBar, Is.LessThan(GeneratorDatasheet.Engine.RatedOilPressureBar)); Assert.That( GeneratorDatasheet.TripPoints.HighCoolantCelsius, Is.GreaterThan( GeneratorDatasheet.Engine.ThermostatCelsius + GeneratorDatasheet.Curves.CoolantRiseKelvin)); }); } /// /// A shutdown-class trip stops a running set; a warning does not. /// [Test] public void AShutdownTripStopsARunningSet() { GeneratorSimulation simulation = SimulationHarness.Create(); Assert.That( SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded), Is.True); Assert.Multiple(() => { Assert.That(simulation.Trip(), Is.True); Assert.That(simulation.State, Is.EqualTo(GeneratorRunState.Fault)); Assert.That(simulation.ProtectionTripped, Is.True); Assert.That(simulation.IsLoaded, Is.False); }); } /// /// One set's trip does not stop another. /// /// /// The interesting failure: a protection evaluated against shared state /// stops the whole plant on one machine's fault. That happened here once. /// [Test] public void OneSetsTripDoesNotStopAnother() { GeneratorSimulation first = SimulationHarness.Create(0); GeneratorSimulation second = SimulationHarness.Create(1); Assert.That(SimulationHarness.AdvanceUntil(first, GeneratorRunState.Loaded), Is.True); Assert.That(SimulationHarness.AdvanceUntil(second, GeneratorRunState.Loaded), Is.True); first.Trip(); Assert.Multiple(() => { Assert.That(first.State, Is.EqualTo(GeneratorRunState.Fault)); Assert.That(second.State, Is.EqualTo(GeneratorRunState.Loaded)); Assert.That(second.ProtectionTripped, Is.False); }); } /// /// A stopped set cannot be tripped again. /// [Test] public void AStoppedSetCannotBeTripped() { GeneratorSimulation simulation = SimulationHarness.Create(); Assert.That(simulation.Trip(), Is.False); } /// /// Every protection can actually fire. /// /// /// The test that would have caught the sample's worst defect. All four /// protections were unreachable: the healthy curves are bounded well inside /// every trip point (that is what a datasheet means), and overload was /// clamped to exactly its own trip point, so a strictly-greater-than /// comparison could never be true. `EvaluateProtections`, the shutdown /// class, `ResetFaults` and the whole Fault branch of the state machine were /// code that never ran, while the README documented them as observable. /// Asserting that a healthy set stays quiet is only half the contract. /// [Test] public void EveryProtectionCanFire() { (GeneratorFault Fault, string Alarm)[] cases = [ (GeneratorFault.OilPressureLoss, "LowOilPressureAlarm"), (GeneratorFault.CoolingFailure, "HighCoolantTemperatureAlarm"), (GeneratorFault.GovernorFailure, "OverspeedAlarm"), (GeneratorFault.Overload, "OverloadAlarm"), ]; foreach ((GeneratorFault fault, string name) in cases) { GeneratorSimulation simulation = SimulationHarness.Create(); Assert.That( SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded), Is.True); ProtectionDefinition definition = Definitions().Single(d => d.Name == name); simulation.InjectFault(fault); bool tripped = false; for (int tick = 0; tick < 400 && !tripped; tick++) { simulation.Advance(tick); tripped = definition.IsTripped(simulation); } Assert.That(tripped, Is.True, $"{name} never tripped on {fault}."); } } /// /// A shutdown-class fault stops the set; a warning-class one does not. /// /// /// Overload is the only warning here. A set that keeps running while /// overloaded is correct - the operator is told, and the machine carries on /// - whereas one that keeps running with no oil pressure is not. /// [Test] public void OnlyShutdownClassFaultsStopTheSet() { GeneratorSimulation warned = RunWithFault(GeneratorFault.Overload); Assert.That( warned.ProtectionTripped, Is.False, "A warning-class protection must not stop the set."); Assert.That( Definitions().Single(d => d.Name == "OverloadAlarm").IsTripped(warned), Is.True); } /// /// Clearing the fault lets the protection clear again. /// /// /// A protection that latches and can never clear is indistinguishable from a /// stuck sensor, and it would leave the alarm retained forever. /// [Test] public void AProtectionClearsWhenTheFaultGoesAway() { GeneratorSimulation simulation = RunWithFault(GeneratorFault.Overload); ProtectionDefinition overload = Definitions().Single(d => d.Name == "OverloadAlarm"); Assert.That(overload.IsTripped(simulation), Is.True); simulation.InjectFault(GeneratorFault.None); for (int tick = 0; tick < 200 && overload.IsTripped(simulation); tick++) { simulation.Advance(tick); } Assert.That(overload.IsTripped(simulation), Is.False); } /// /// A shutdown trip removes the very condition that caused it. /// /// /// This is why a shutdown-class alarm has to latch. Oil pressure and coolant /// temperature are only supervised while the engine turns, so the moment the /// trip stops the set the condition reads healthy again. An alarm that simply /// followed its condition would go active for one tick and clear, leaving an /// operator with a stopped machine and no indication of why it stopped - /// which was the observed behaviour before the latch was added, and was /// invisible to every test that only checked the condition. /// [Test] public void AShutdownTripRemovesTheConditionThatCausedIt() { GeneratorSimulation simulation = SimulationHarness.Create(); SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded); ProtectionDefinition coolant = Definitions() .Single(d => d.Name == "HighCoolantTemperatureAlarm"); simulation.InjectFault(GeneratorFault.CoolingFailure); for (int tick = 0; tick < 400 && !coolant.IsTripped(simulation); tick++) { simulation.Advance(tick); } Assert.That(coolant.IsTripped(simulation), Is.True, "Never tripped."); simulation.Trip(); Assert.Multiple(() => { Assert.That(simulation.State, Is.EqualTo(GeneratorRunState.Fault)); Assert.That( coolant.IsTripped(simulation), Is.False, "The condition should read healthy once the set has stopped."); }); } /// /// A shutdown alarm latches once active; a warning alarm does not. /// [Test] public void OnlyShutdownAlarmsLatch() { var shutdown = new ProtectionAlarm(null!, () => false, isShutdown: true); var warning = new ProtectionAlarm(null!, () => false, isShutdown: false); Assert.Multiple(() => { Assert.That(shutdown.IsLatched, Is.False, "Not latched before it goes active."); Assert.That(warning.IsLatched, Is.False); }); shutdown.WasActive = true; warning.WasActive = true; Assert.Multiple(() => { Assert.That(shutdown.IsLatched, Is.True, "A shutdown alarm holds until reset."); Assert.That(warning.IsLatched, Is.False, "A warning alarm follows its condition."); }); } private static GeneratorSimulation RunWithFault(GeneratorFault fault) { GeneratorSimulation simulation = SimulationHarness.Create(); SimulationHarness.AdvanceUntil(simulation, GeneratorRunState.Loaded); simulation.InjectFault(fault); for (int tick = 0; tick < 200; tick++) { simulation.Advance(tick); } return simulation; } private static List Definitions() { var definitions = new List(); for (int index = 0; index < GeneratorProtections.Definitions.Count; index++) { definitions.Add(GeneratorProtections.Definitions[index]); } return definitions; } } }