/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Concurrent; using System.Collections.Generic; using Opc.Ua; using Opc.Ua.Generators; namespace Generators { /// /// The protection alarms a generating set annunciates. /// /// /// /// One GeneratorProtectionAlarmType instance per protection function /// rather than a single instance whose ProtectionFunction changes: a set /// can trip on low oil pressure and overspeed at the same moment, and an /// operator needs to see both. It is also how a real control panel annunciates. /// /// /// Because OffNormalAlarmType takes healthy as the normal state, /// each instance carries the supervised input and its normal value so a client /// can find what is actually being watched. /// /// public partial class GeneratorNodeManager { private readonly ConcurrentDictionary> m_alarms = new(); /// /// Creates the protection alarm nodes for one set. /// /// The generator set. /// /// Called while the set is being materialised, before its subtree is /// registered, so the alarms register along with everything else. Building /// them later would mean registering each one separately from a synchronous /// context, which is how sync-over-async creeps in. /// private void MaterialiseProtectionAlarms(GeneratorSetState set) { ushort ns = (ushort)Server.NamespaceUris.GetIndex( Opc.Ua.Generators.Namespaces.Generators); foreach (ProtectionDefinition definition in GeneratorProtections.Definitions) { Create(set, ns, definition); } } /// /// Binds the trip conditions once the simulation for a set exists. /// /// The generator set. /// The set's simulation. private void AttachProtectionAlarms(GeneratorSetState set, GeneratorSimulation simulation) { var alarms = new List(); foreach (ProtectionDefinition definition in GeneratorProtections.Definitions) { if (FindPredefinedNode( AlarmNodeId(set, definition.Name)) is not { } alarm) { continue; } alarms.Add(new ProtectionAlarm( alarm, () => definition.IsTripped(simulation), definition.IsShutdown)); } m_alarms[set.NodeId] = alarms; } /// /// Returns the NodeId an alarm is minted with. /// /// Owning generator set. /// Alarm browse name. /// The alarm's NodeId. private NodeId AlarmNodeId(GeneratorSetState set, string name) { return new NodeId( $"{set.NodeId.IdentifierAsString}_{name}", InstanceNamespaceIndex); } /// /// Creates and configures one protection alarm node. /// /// Owning generator set. /// Generators namespace index. /// What the alarm reports. private void Create(GeneratorSetState set, ushort ns, ProtectionDefinition definition) { GeneratorProtectionAlarmState alarm = SystemContext .CreateInstanceOfGeneratorProtectionAlarmType( set, new QualifiedName(definition.Name, ns)); alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent; alarm.SymbolicName = definition.Name; GeneratorProtections.ApplyDefinition(alarm, SystemContext, definition); set.AddChild(alarm); alarm.CreateOrReplaceEventType(SystemContext, null!).Value = NodeId.Create( Opc.Ua.Generators.ObjectTypes.GeneratorProtectionAlarmType, Opc.Ua.Generators.Namespaces.Generators, Server.NamespaceUris); alarm.CreateOrReplaceSourceNode(SystemContext, null!).Value = set.NodeId; alarm.CreateOrReplaceSourceName(SystemContext, null!).Value = set.BrowseName.Name ?? string.Empty; alarm.CreateOrReplaceConditionName(SystemContext, null!).Value = definition.Name; alarm.CreateOrReplaceSeverity(SystemContext, null!).Value = definition.Severity; alarm.CreateOrReplaceRetain(SystemContext, null!).Value = false; alarm.CreateOrReplaceTime(SystemContext, null!).Value = DateTime.UtcNow; alarm.CreateOrReplaceReceiveTime(SystemContext, null!).Value = DateTime.UtcNow; alarm.CreateOrReplaceMessage(SystemContext, null!).Value = new LocalizedText("Protection healthy."); // OffNormalAlarmType treats healthy as normal, so record what is being // supervised rather than leaving a client to guess. NodeId input = definition.Name switch { "LowOilPressureAlarm" => set.LubricationSystem?.OilPressure?.NodeId ?? NodeId.Null, "HighCoolantTemperatureAlarm" => set.Engine?.CoolantTemperature?.NodeId ?? NodeId.Null, "OverspeedAlarm" => set.Engine?.Speed?.NodeId ?? NodeId.Null, _ => set.Alternator?.LoadPercent?.NodeId ?? NodeId.Null, }; if (!input.IsNull) { alarm.CreateOrReplaceInputNode(SystemContext, null!).Value = input; } alarm.SetEnableState(SystemContext, true); alarm.SetActiveState(SystemContext, false); } /// /// Evaluates every set's protections and reports state changes as events. /// /// /// The trip conditions come from the simulation, which already applies /// hysteresis, so an alarm latches and clears cleanly instead of chattering /// on the threshold. /// private void EvaluateProtections() { foreach (KeyValuePair> entry in m_alarms) { if (!m_simulations.TryGetValue(entry.Key, out GeneratorSimulation? simulation)) { continue; } bool shutdown = false; foreach (ProtectionAlarm alarm in entry.Value) { bool active = alarm.IsTripped(); // A shutdown-class protection latches. Stopping the engine is // what removes the condition that tripped it - oil pressure and // coolant temperature are only supervised while the set turns - // so following the condition would clear the alarm on the very // next tick and leave an operator with a stopped machine and no // indication of why. It stays annunciated until ResetFaults, // which is what a real control panel does. A warning-class // protection does not stop anything, so it follows its condition. if (alarm.IsLatched) { continue; } if (active == alarm.WasActive) { continue; } alarm.WasActive = active; Annunciate(alarm, active); shutdown |= active && alarm.IsShutdown; } // A shutdown-class protection stops the machine; a warning does not. // Reporting a trip without stopping the set would publish a generator // that is on fire and still loaded. // // Tripping is deferred until every protection has been evaluated: // stopping the set mid-loop makes its remaining conditions read // healthy, so a set that lost oil pressure and overspeed at the same // moment would annunciate only whichever came first in the table - // exactly the case one-alarm-per-function exists to show. if (shutdown) { simulation.Trip(); } } } /// /// Publishes one alarm's new active state and reports it as an event. /// /// The alarm whose state changed. /// Whether the protection is now tripped. /// /// A client learns of condition state changes only through events, so the /// node update and the event have to travel together. Updating the node /// alone leaves an alarm-list client showing a stale condition until it /// happens to issue a ConditionRefresh. /// private void Annunciate(ProtectionAlarm alarm, bool active) { DateTime now = DateTime.UtcNow; alarm.State.SetActiveState(SystemContext, active); alarm.State.CreateOrReplaceRetain(SystemContext, null!).Value = active; alarm.State.CreateOrReplaceTime(SystemContext, null!).Value = now; alarm.State.CreateOrReplaceReceiveTime(SystemContext, null!).Value = now; alarm.State.CreateOrReplaceMessage(SystemContext, null!).Value = new LocalizedText( active ? "Protection tripped." : "Protection cleared."); alarm.State.ClearChangeMasks(SystemContext, true); var snapshot = new InstanceStateSnapshot(); snapshot.Initialize(SystemContext, alarm.State); alarm.State.ReportEvent(SystemContext, snapshot); } /// /// Clears the latched protections of one set. /// /// The generator set. /// /// Each clear is reported as an event, for the same reason a trip is: a /// client that saw the alarm go active learns it is over only from the /// event, so clearing the node silently would leave the alarm displayed as /// active and retained indefinitely. /// private void ClearLatchedAlarms(GeneratorSetState set) { if (!m_alarms.TryGetValue(set.NodeId, out List? alarms)) { return; } foreach (ProtectionAlarm alarm in alarms) { if (!alarm.WasActive) { continue; } alarm.WasActive = false; Annunciate(alarm, false); } } } /// /// The protections every generating set annunciates. /// /// /// A table rather than a switch, so a client-visible property and the condition /// that drives it are declared next to each other and cannot drift apart. Held /// to the datasheet's trip points by GeneratorProtectionTests. /// internal static class GeneratorProtections { /// /// Gets the protections, in the order a set publishes them. /// public static ArrayOf Definitions { get; } = new ProtectionDefinition[] { new ProtectionDefinition("LowOilPressureAlarm", GeneratorProtectionFunctionEnum.LowOilPressure, "LubricationSystem", IsShutdown: true, Severity: 900, s => s.LowOilPressure), new ProtectionDefinition("HighCoolantTemperatureAlarm", GeneratorProtectionFunctionEnum.HighCoolantTemperature, "CoolingSystem", IsShutdown: true, Severity: 900, s => s.CoolantOverTemperature), new ProtectionDefinition("OverspeedAlarm", GeneratorProtectionFunctionEnum.Overspeed, "Engine", IsShutdown: true, Severity: 1000, s => s.SpeedRpm > GeneratorDatasheet.TripPoints.OverspeedRpm), new ProtectionDefinition("OverloadAlarm", GeneratorProtectionFunctionEnum.Overload, "Alternator", IsShutdown: false, Severity: 600, s => s.LoadFraction > GeneratorDatasheet.TripPoints.OverloadFraction), }; /// /// Writes what a protection reports onto one alarm node. /// /// The alarm node to configure. /// The system context. /// What the alarm reports. /// /// ProtectionFunction is mandatory on the type, so the generated /// factory has already materialised it. IsShutdown and /// SubsystemName are optional and have not been. /// CreateOrReplace alone is not enough for those: it produces a child /// with no ReferenceTypeId and no NodeId, so there is no reference for /// a browse to follow and the alarm reports its trip without saying whether /// the trip stops the machine or which subsystem to go to. Opting in with /// AddXxx first is what gives the child its HasProperty /// reference. Every write looks correct in the source; the loss is only /// visible from a client. /// public static void ApplyDefinition( GeneratorProtectionAlarmState alarm, ISystemContext context, ProtectionDefinition definition) { alarm.AddIsShutdown(context); alarm.AddSubsystemName(context); alarm.CreateOrReplaceProtectionFunction(context, null!).Value = definition.Function; alarm.CreateOrReplaceIsShutdown(context, null!).Value = definition.IsShutdown; alarm.CreateOrReplaceSubsystemName(context, null!).Value = definition.Subsystem; } } /// /// One protection a generating set annunciates. /// /// Browse name of the alarm. /// Which protection this alarm reports. /// Subsystem the fault belongs to. /// Whether tripping shuts the set down. /// Event severity. /// Evaluates the trip condition against a simulation. internal sealed record ProtectionDefinition( string Name, GeneratorProtectionFunctionEnum Function, string Subsystem, bool IsShutdown, ushort Severity, Func IsTripped); /// /// One protection alarm and the condition that trips it. /// /// The alarm node. /// Evaluates the trip condition. /// Whether tripping shuts the set down. internal sealed class ProtectionAlarm( GeneratorProtectionAlarmState state, Func isTripped, bool isShutdown) { /// /// Gets the alarm node. /// public GeneratorProtectionAlarmState State { get; } = state; /// /// Gets whether tripping this protection shuts the set down. /// public bool IsShutdown { get; } = isShutdown; /// /// Gets or sets the last reported active state, so only changes raise events. /// public bool WasActive { get; set; } /// /// Gets a value indicating whether the alarm is latched on. /// /// /// A shutdown-class protection holds its annunciation until it is reset, /// because the shutdown removes the condition that caused it: oil pressure /// and coolant temperature are only supervised while the engine turns, so an /// alarm that followed its condition would clear on the tick after the trip /// and leave an operator with a stopped machine and no indication of why. /// public bool IsLatched => IsShutdown && WasActive; /// /// Evaluates the trip condition. /// /// when the protection is tripped. public bool IsTripped() { return isTripped(); } } }