/* ======================================================================== * Copyright (c) 2005-2026 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.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Pumps; using static PumpsSample.PumpDatasheet; using BrowseNames = Opc.Ua.Pumps.BrowseNames; namespace PumpsSample { /// /// Runs the duty/standby pair once a second. /// /// /// /// One pump runs while the other stands by, and every /// seconds they swap: /// the time-based distribution OPC 40223 calls /// ConcerningTimeDistribution. The running pump's flow wanders /// around the rated point; pressure, power, efficiency and motor current /// follow it. /// /// /// Once a minute the running pump reports cavitation for a few seconds, /// so a client watching the supervision group sees a signal raise and /// clear. /// /// internal sealed class PumpSimulation : BackgroundService { public PumpSimulation(PumpFleet fleet, ILogger logger) { m_fleet = fleet; m_logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { while (!stoppingToken.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken).ConfigureAwait(false); IReadOnlyList units = m_fleet.Pumps; if (units.Count == 0) { // ConfigurePumps has not run yet. continue; } try { Advance(units); } catch (Exception ex) { m_logger.SimulationTickFailed(ex); } } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { // Host shutdown. } } private void Advance(IReadOnlyList units) { long tick = ++m_tick; int duty = (int)(tick / ExchangeIntervalSeconds % units.Count); for (int ii = 0; ii < units.Count; ii++) { Update(units[ii], running: ii == duty, tick); } } private void Update(PumpUnit unit, bool running, long tick) { bool known = m_running.TryGetValue(unit.Name, out bool wasRunning); m_running[unit.Name] = running; if (!known || wasRunning != running) { ChangeRole(unit, running, started: known && running); } double load = running ? 0.85 + (0.1 * Math.Sin(tick / 15.0)) : 0; unit.Analog(unit.Measurements, BrowseNames.MassFlow, RatedMassFlow * load); unit.Analog( unit.Measurements, BrowseNames.DifferentialPressure, running ? RatedDifferentialPressure * (1.2 - (0.2 * load * load)) : 0); unit.Analog( unit.Measurements, BrowseNames.Speed, running ? RatedSpeed * (0.97 + (0.01 * Math.Sin(tick / 7.0))) : 0); unit.Analog( unit.Measurements, BrowseNames.PumpPowerInput, running ? RatedPowerInput * Math.Pow(load, 0.9) : 0); unit.Analog( unit.Measurements, BrowseNames.PumpEfficiency, running ? BestEfficiency * (1 - (2 * (load - 1) * (load - 1))) : 0); // The bearing warms towards its running temperature and cools // towards ambient while standing by. double bearing = m_bearing.TryGetValue(unit.Name, out double last) ? last : 25; bearing += ((running ? BearingTemperature : 25) - bearing) * 0.05; m_bearing[unit.Name] = bearing; unit.Analog(unit.Measurements, BrowseNames.BearingTemperature, bearing); unit.Analog(unit.Motor, BrowseNames.MotorCurrent, running ? 21.5 * Math.Pow(load, 0.9) : 0); unit.Analog(unit.Motor, BrowseNames.MotorTemperature, bearing + 8); // Vibration rises during a cavitation episode, as it would on a // real pump; 2.8 mm/s is zone A/B of ISO 10816-3 for this class. bool cavitating = running && tick % 60 is >= 40 and < 46; unit.Analog( unit.Vibration, BrowseNames.OverallVibrationVelocityRMS, running ? (cavitating ? 7.1 : 2.8 + (0.2 * Math.Sin(tick / 5.0))) : 0.1); // Supervision: a short cavitation episode once a minute. Only the // transitions are written, as a real supervision would report them. bool cavitation = running && tick % 60 is >= 40 and < 46; if (cavitation != m_cavitation.Contains(unit.Name)) { if (cavitation) { m_cavitation.Add(unit.Name); m_logger.CavitationRaised(unit.Name); } else { m_cavitation.Remove(unit.Name); } unit.Discrete(unit.ProcessFluid, BrowseNames.Cavitation, cavitation); } if (running) { double hours = m_hours.TryGetValue(unit.Name, out double h) ? h : InitialOperatingHours; hours += 1.0 / 3600.0; m_hours[unit.Name] = hours; unit.Analog(unit.GeneralMaintenance, BrowseNames.OperatingTime, hours); } } /// /// Writes a duty/standby exchange: signals, multi-pump role and /// maintenance state change only here, not every second. /// private void ChangeRole(PumpUnit unit, bool running, bool started) { unit.Discrete(unit.Signals, BrowseNames.PumpOperation, running); unit.Discrete(unit.Signals, BrowseNames.StandBy, !running); unit.Value( unit.MultiPump, BrowseNames.PumpRole, Variant.From(running ? PumpRoleEnum.Master : PumpRoleEnum.Slave)); unit.Value( unit.GeneralMaintenance, BrowseNames.StateOfTheItem, Variant.From(running ? StateOfTheItemEnum.OperatingState : StateOfTheItemEnum.StandByState)); if (running) { unit.Value(unit.MultiPump, BrowseNames.ExchangeTime, Variant.From(DateTimeUtc.Now)); m_logger.DutyExchanged(unit.Name); } if (started) { uint starts = m_starts.TryGetValue(unit.Name, out uint count) ? count + 1 : 1; m_starts[unit.Name] = starts; unit.Value(unit.Measurements, BrowseNames.NumberOfStarts, Variant.From(starts)); } } private readonly PumpFleet m_fleet; private readonly ILogger m_logger; private readonly Dictionary m_running = []; private readonly HashSet m_cavitation = []; private readonly Dictionary m_bearing = []; private readonly Dictionary m_hours = []; private readonly Dictionary m_starts = []; private long m_tick; } internal static partial class PumpSimulationLog { [LoggerMessage( EventId = 1, Level = LogLevel.Information, Message = "{Pump} took over duty.")] public static partial void DutyExchanged(this ILogger logger, string pump); [LoggerMessage( EventId = 2, Level = LogLevel.Warning, Message = "{Pump} reports cavitation.")] public static partial void CavitationRaised(this ILogger logger, string pump); [LoggerMessage( EventId = 3, Level = LogLevel.Error, Message = "A simulation tick failed.")] public static partial void SimulationTickFailed(this ILogger logger, Exception exception); } }