Files
Copilot d525bc3f28 Bound reconnect cleanup and organize samples by domain (#4276)
# Description

This PR fixes bounded ManagedSession reconnect cleanup and reorganizes
the sample applications into domain-focused folders.

## Reconnect cleanup

- Bounds reconnect integration cleanup with independent 30-second close
and dispose limits.
- Sends `CloseSession` through the awaited transport request path during
`Session.DisposeAsync`.
- Preserves explicit `Session.CloseAsync` behavior that waits for or
cancels outstanding publish requests.
- Covers the disposal request path with a regression test.

## Sample organization

- `samples/OpenUsd`: `GeneratorServer`, `SiteCompositionServer`
- `samples/Redundancy`: `RedundantClient`, `RedundantServer`,
`RedundantPubSub`
- `samples/Isa95`: `MinimalIsa95Server`
- `samples/MinimalApi`: `MinimalBoilerServer`, `MinimalCalcServer`,
`MinimalClient`
- `samples/DI`: `PumpDeviceIntegrationServer`
- `samples/Lds`: `ConsoleLdsServer`
- `samples/Reference`: `ConsoleReferenceClient`,
`ConsoleReferenceServer`
- `samples/PubSub`: `ConsoleReferencePubSubClient`

`UA.slnx` now exposes the same requested domain groups. Project
references, tests, runtime sample probes, CI workflows, Dockerfiles,
Compose files, helper scripts, and documentation were updated to the new
paths. `samples/Robotics/MinimalRobotServer` remains under `Robotics`.

## Validation

- `dotnet build UA.slnx`
- 3 Redundancy short-haul sample tests
- 70 DI sample tests
- 914 OpenUSD tests
- 1 WotCon model-materialization test
- 137 AOT tests
- `docker compose config` for all 6 moved Redundancy Compose files

## Related Issues

- Fixes #4273

## Checklist

- [ ] 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 built the complete `UA.slnx` solution locally.
- [ ] I ran all tests locally against at least .NET Framework and .NET
10.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [x] I have addressed all PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Co-authored-by: Marc Schier <mschier@microsoft.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Copilot-Session: a2c2838f-74ae-4997-a1d4-50574d9e0636
2026-08-21 04:35:47 +00:00

394 lines
18 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 System.Collections.Concurrent;
using System.Threading;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Generators;
using Opc.Ua.Server.Fluent;
namespace Generators
{
/// <summary>
/// The datasheet-driven simulation, the operating state machine and the
/// protection alarms.
/// </summary>
public partial class GeneratorNodeManager
{
private readonly ConcurrentDictionary<NodeId, GeneratorSimulation> m_simulations = new();
private readonly Lock m_registrationLock = new();
/// <summary>
/// Serialises the simulation tick against client method calls.
/// </summary>
/// <remarks>
/// The tick runs on a thread-pool thread and method calls arrive on request
/// threads, and both drive state transitions and write the same address-space
/// nodes. One gate for the whole plant rather than one per set: a tick is
/// microseconds of arithmetic, so the contention is irrelevant, and a single
/// gate cannot be taken in the wrong order.
/// </remarks>
private readonly Lock m_simulationGate = new();
private long m_ticks;
private int m_nextProfile;
private GeneratorSimulation? m_faultSubject;
/// <summary>
/// Seconds between the start of one fault cycle and the next.
/// </summary>
private const double FaultCycleSeconds = 150.0;
/// <summary>
/// Seconds a fault is left in place, long enough to trip and annunciate.
/// </summary>
private const double FaultDwellSeconds = 20.0;
/// <summary>
/// The faults the schedule rotates through, one per cycle, so a long run
/// exercises every protection rather than the same one repeatedly.
/// </summary>
private static readonly GeneratorFault[] s_faultRotation =
[
GeneratorFault.CoolingFailure,
GeneratorFault.OilPressureLoss,
GeneratorFault.Overload,
GeneratorFault.GovernorFailure,
];
/// <summary>
/// Wires the simulation for every materialised set and starts the tick.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
partial void Configure(INodeManagerBuilder builder)
{
foreach (GeneratorSetState set in m_generatorSets)
{
RegisterGeneratorSimulation(builder, set);
}
builder.Simulation(SimulationInterval)
.OnTick((ctx, elapsed) => AdvanceSimulation());
}
/// <summary>
/// Wires a set created after the initial fluent configuration into the
/// already-running simulation.
/// </summary>
/// <param name="set">The registered generator set.</param>
private void RegisterGeneratorSimulation(GeneratorSetState set)
{
ushort ns = (ushort)Server.NamespaceUris.GetIndex(
Opc.Ua.Generators.Namespaces.Generators);
NodeManagerBuilder builder = CreateFluentBuilder(ns);
RegisterGeneratorSimulation(builder, set);
builder.Seal();
}
/// <summary>
/// Binds every measured variable of one set and records its simulation state.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
/// <param name="set">The generator set to wire.</param>
private void RegisterGeneratorSimulation(INodeManagerBuilder builder, GeneratorSetState set)
{
int profile;
lock (m_registrationLock)
{
profile = m_nextProfile++;
}
EngineState engine = set.Engine!;
AlternatorState alternator = set.Alternator!;
// Engine.
Analog(builder, engine.Speed!.NodeId, EngineeringUnits.RevolutionsPerMinute,
0, GeneratorDatasheet.Ranges.SpeedMaxRpm, out IValueUpdater<double> speed);
Analog(builder, engine.OilPressure!.NodeId, EngineeringUnits.Pascal,
0, GeneratorDatasheet.Convert.ToPascal(GeneratorDatasheet.Ranges.OilPressureMaxBar),
out IValueUpdater<double> oilPressure);
Analog(builder, engine.CoolantTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.CoolantMaxCelsius),
out IValueUpdater<double> coolantTemperature);
Analog(builder, engine.ExhaustGasTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(GeneratorDatasheet.Ranges.ExhaustMaxCelsius),
out IValueUpdater<double> exhaustTemperature);
Analog(builder, engine.FuelRate!.NodeId, EngineeringUnits.CubicMetrePerSecond,
0,
GeneratorDatasheet.Convert.ToCubicMetresPerSecond(
GeneratorDatasheet.Ranges.FuelRateMaxLitresPerHour),
out IValueUpdater<double> fuelRate);
Analog(builder, engine.EngineHours!.NodeId, EngineeringUnits.Hour,
0, 100_000, out IValueUpdater<double> engineHours);
Analog(builder, engine.PercentLoad!.NodeId, EngineeringUnits.Percent,
0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater<double> percentLoad);
builder.Variable<uint>(engine.NumberOfStarts!.NodeId)
.Bind(out IValueUpdater<uint> starts);
// Alternator aggregates.
Analog(builder, alternator.Frequency!.NodeId, EngineeringUnits.Hertz,
GeneratorDatasheet.Ranges.FrequencyMinHertz,
GeneratorDatasheet.Ranges.FrequencyMaxHertz, out IValueUpdater<double> frequency);
Analog(builder, alternator.TotalRealPower!.NodeId, EngineeringUnits.Watt,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater<double> realPower);
Analog(builder, alternator.TotalApparentPower!.NodeId, EngineeringUnits.VoltAmpere,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts / GeneratorDatasheet.Electrical.RatedPowerFactor,
out IValueUpdater<double> apparentPower);
Analog(builder, alternator.AverageLineToLineVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.VoltageMaxVolts, out IValueUpdater<double> voltage);
Analog(builder, alternator.AverageCurrent!.NodeId, EngineeringUnits.Ampere,
0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater<double> current);
Analog(builder, alternator.LoadPercent!.NodeId, EngineeringUnits.Percent,
0, GeneratorDatasheet.Ranges.LoadPercentMax, out IValueUpdater<double> loadPercent);
Analog(builder, alternator.TotalRealEnergy!.NodeId, EngineeringUnits.WattHour,
0, 1e9, out IValueUpdater<double> energy);
builder.Variable<double>(alternator.AveragePowerFactor!.NodeId)
.Bind(out IValueUpdater<double> powerFactor);
// Per-phase values.
var phases = new PhaseUpdaters[3];
AlternatorPhaseState[] phaseStates = [alternator.L1!, alternator.L2!, alternator.L3!];
for (int i = 0; i < phaseStates.Length; i++)
{
AlternatorPhaseState phase = phaseStates[i];
Analog(builder, phase.LineToNeutralVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.VoltageMaxVolts, out IValueUpdater<double> pv);
Analog(builder, phase.Current!.NodeId, EngineeringUnits.Ampere,
0, GeneratorDatasheet.Ranges.CurrentMaxAmperes, out IValueUpdater<double> pi);
Analog(builder, phase.RealPower!.NodeId, EngineeringUnits.Watt,
0, GeneratorDatasheet.Ranges.RealPowerMaxWatts, out IValueUpdater<double> pp);
builder.Variable<double>(phase.PowerFactor!.NodeId)
.Bind(out IValueUpdater<double> ppf);
phases[i] = new PhaseUpdaters(pv, pi, pp, ppf);
}
// Balance of plant.
Analog(builder, set.FuelSystem!.FuelLevel!.NodeId, EngineeringUnits.Percent,
0, 100, out IValueUpdater<double> fuelLevel);
Analog(builder, set.FuelSystem!.TotalFuelConsumed!.NodeId, EngineeringUnits.Litre,
0, 1e6, out IValueUpdater<double> fuelConsumed);
Analog(builder, set.CoolingSystem!.AmbientTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(-40),
GeneratorDatasheet.Convert.ToKelvin(60), out IValueUpdater<double> ambient);
Analog(builder, set.LubricationSystem!.OilTemperature!.NodeId, EngineeringUnits.Kelvin,
GeneratorDatasheet.Convert.ToKelvin(0),
GeneratorDatasheet.Convert.ToKelvin(150), out IValueUpdater<double> oilTemperature);
Analog(builder, set.StartingSystem!.BatteryVoltage!.NodeId, EngineeringUnits.Volt,
0, GeneratorDatasheet.Ranges.BatteryVoltageMaxVolts,
out IValueUpdater<double> batteryVoltage);
builder.Variable<bool>(set.GeneratorBreakerClosed!.NodeId)
.Bind(out IValueUpdater<bool> breakerClosed);
builder.Variable<bool>(set.AvailableToLoad!.NodeId)
.Bind(out IValueUpdater<bool> availableToLoad);
var simulation = new GeneratorSimulation(
profile,
SimulationInterval,
new EngineUpdaters(
speed, oilPressure, coolantTemperature, exhaustTemperature,
fuelRate, engineHours, percentLoad, starts),
new AlternatorUpdaters(
frequency, realPower, apparentPower, voltage, current,
loadPercent, energy, powerFactor, phases),
new PlantUpdaters(
fuelLevel, fuelConsumed, ambient, oilTemperature,
batteryVoltage, breakerClosed, availableToLoad));
m_simulations[set.NodeId] = simulation;
// The first set registered is the one the fault schedule drives. It is
// the machine nearest the hero camera, so a trip is visible in the
// viewport rather than happening to a set that is off the far end of
// the row - an alarm nobody can see demonstrates nothing.
m_faultSubject ??= simulation;
AttachSimulationToTwin(set, simulation);
AttachProtectionAlarms(set, simulation);
AttachStateMachine(set, simulation);
}
/// <summary>
/// Lets one set develop a fault on a slow rotation, so the protection
/// path is exercised in a running plant.
/// </summary>
/// <param name="tick">Monotonic tick counter.</param>
/// <remarks>
/// <para>
/// A set running to its datasheet cannot protect-trip: the healthy curves
/// are bounded well inside every trip point, which is what a datasheet
/// means. Without a deliberate excursion the four alarms, the shutdown
/// class, <c>ResetFaults</c> and the whole Fault branch of the state machine
/// would be code that never runs - and a sample that documents alarms which
/// never fire is worse than one that has none.
/// </para>
/// <para>
/// Confined to a single set so the rest of the plant stays a clean
/// reference, and disabled entirely with <c>--faults false</c>.
/// </para>
/// </remarks>
private void DriveFaultSchedule(long tick)
{
if (!InjectFaults || m_simulations.IsEmpty)
{
return;
}
double seconds = tick * SimulationInterval.TotalSeconds;
int slot = (int)(seconds / FaultCycleSeconds);
double intoSlot = seconds - (slot * FaultCycleSeconds);
// Healthy for most of each cycle, then one fault long enough to trip,
// annunciate and shut the set down before the next cycle clears it.
GeneratorFault fault = intoSlot < FaultCycleSeconds - FaultDwellSeconds
? GeneratorFault.None
: s_faultRotation[slot % s_faultRotation.Length];
GeneratorSimulation? subject = m_faultSubject;
if (subject != null && subject.Fault != fault)
{
subject.InjectFault(fault);
}
}
/// <summary>
/// Advances every set by one tick.
/// </summary>
/// <remarks>
/// Runs on a thread-pool thread while client requests are served on their
/// own threads, so it is serialised against the method handlers by
/// <see cref="m_simulationGate"/>. Without that, a tick and a concurrent
/// EmergencyStop can both transition the same set and race on the paired
/// CurrentState / CurrentState.Id write, leaving a client with a state name
/// that does not match the state node beside it.
/// </remarks>
private void AdvanceSimulation()
{
long tick = Interlocked.Increment(ref m_ticks);
double seconds = SimulationInterval.TotalSeconds;
lock (m_simulationGate)
{
foreach (GeneratorSimulation simulation in m_simulations.Values)
{
simulation.Advance(tick);
}
DriveFaultSchedule(tick);
foreach (GeneratorTwin twin in m_twins.Values)
{
twin.AdvanceFan(seconds);
}
EvaluateProtections();
}
PublishOpenUsdSignals();
}
/// <summary>
/// Binds an analog variable with its engineering units and range.
/// </summary>
/// <param name="builder">The active fluent builder.</param>
/// <param name="nodeId">The variable to bind.</param>
/// <param name="units">Engineering units published as a property.</param>
/// <param name="min">Lower bound of the engineering range.</param>
/// <param name="max">Upper bound of the engineering range.</param>
/// <param name="updater">Receives the updater the simulation writes through.</param>
private static void Analog(
INodeManagerBuilder builder,
NodeId nodeId,
EUInformation units,
double min,
double max,
out IValueUpdater<double> updater)
{
builder.Variable<double>(nodeId)
.Bind(out updater)
.WithEngineeringUnits(units)
.WithEURange(min, max);
}
/// <summary>
/// The UNECE/CEFACT units this sample publishes.
/// </summary>
/// <remarks>
/// The companion specification types every measured value as
/// <c>AnalogUnitType</c> carrying a machine-readable unit, so a client can
/// discover the unit from the model alone rather than guessing from a name.
/// </remarks>
private static class EngineeringUnits
{
private const string Cefact = "http://www.opcfoundation.org/UA/units/un/cefact";
public static readonly EUInformation RevolutionsPerMinute =
new("r/min", "Revolutions per Minute", Cefact);
public static readonly EUInformation Pascal = new("Pa", "Pascal", Cefact);
public static readonly EUInformation Kelvin = new("K", "Kelvin", Cefact);
public static readonly EUInformation CubicMetrePerSecond =
new("m3/s", "Cubic Metre per Second", Cefact);
public static readonly EUInformation Hour = new("h", "Hour", Cefact);
public static readonly EUInformation Percent = new("%", "Percent", Cefact);
public static readonly EUInformation Hertz = new("Hz", "Hertz", Cefact);
public static readonly EUInformation Watt = new("W", "Watt", Cefact);
public static readonly EUInformation VoltAmpere = new("VA", "Volt Ampere", Cefact);
public static readonly EUInformation Volt = new("V", "Volt", Cefact);
public static readonly EUInformation Ampere = new("A", "Ampere", Cefact);
public static readonly EUInformation WattHour = new("W h", "Watt Hour", Cefact);
public static readonly EUInformation Litre = new("l", "Litre", Cefact);
}
}
/// <summary>
/// Source-generated log messages for the generator simulation.
/// </summary>
internal static partial class GeneratorSimulationLog
{
[LoggerMessage(
EventId = GeneratorServerEventIds.GeneratorSimulation + 0,
Level = LogLevel.Debug,
Message = "Generator set {NodeId} entered state {State}.")]
public static partial void GeneratorStateChanged(
this ILogger logger, NodeId nodeId, GeneratorRunState state);
}
}