/* ======================================================================== * 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.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Opc.Ua; using Opc.Ua.Di; using Opc.Ua.Di.Server; using Opc.Ua.Di.Server.Builders; using Opc.Ua.Di.Server.Hosting; using Opc.Ua.Generators; using Opc.Ua.IA; using Opc.Ua.Machinery; using Opc.Ua.OpenUsd; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.NodeManager; namespace Generators { /// /// Runtime options for the generator-set device-integration sample. /// public sealed class GeneratorDeviceIntegrationOptions { /// /// Gets or sets how many simulated generator sets are materialised. /// public int GeneratorCount { get; set; } = 2; /// /// Gets or sets the interval used by the live simulation loop. /// public TimeSpan SimulationInterval { get; set; } = TimeSpan.FromMilliseconds(250); /// /// Gets or sets whether one set develops faults on a slow rotation. /// /// /// On by default. A set running to its datasheet stays well inside every /// trip point, so without a deliberate excursion the protection alarms, the /// shutdown class and the Fault branch of the state machine are all code /// that never runs. Turn it off to watch a plant that is purely healthy. /// public bool InjectFaults { get; set; } = true; } /// /// Node manager for the Generators companion-specification server. /// /// /// /// This partial carries the infrastructure - construction, address-space load /// and instance materialisation. The datasheet-driven simulation, the twelve /// state operating state machine and the protection alarms live in /// GeneratorNodeManager.Configure.cs; the OpenUSD twin lives in /// OpenUsdRepresentation.cs. /// /// /// Unlike the pump sample - whose PumpType derives from the Machinery /// MachineType and so cannot use the DI device helpers - /// GeneratorSetType derives from the DI DeviceType, so sets are /// created through and /// get the DI nameplate, registration and topology wiring for free. /// /// public partial class GeneratorNodeManager : DiNodeManager { private readonly GeneratorDeviceIntegrationOptions m_options; private readonly List m_generatorSets = []; /// /// Wires the simulation, state machines and alarms against the /// materialised instances. Implemented in /// GeneratorNodeManager.Configure.cs. /// /// The active fluent builder. partial void Configure(INodeManagerBuilder builder); /// /// Initialises a new without DI-hosting /// integration. /// /// The server the manager belongs to. /// The application configuration. public GeneratorNodeManager( IServerInternal server, ApplicationConfiguration configuration) : this(server, configuration, postSetupRunner: null, options: null) { } /// /// Initialises a new that participates in /// the DI hosting post-setup pipeline. /// /// The server the manager belongs to. /// The application configuration. /// Optional DI post-setup pipeline. /// Optional sample options. /// /// The configured generator count or simulation interval is out of range. /// public GeneratorNodeManager( IServerInternal server, ApplicationConfiguration configuration, IDiPostSetupRunner? postSetupRunner, IOptions? options = null) : base( server, configuration, postSetupRunner, Opc.Ua.Generators.Namespaces.Generators, Opc.Ua.Machinery.Namespaces.Machinery, // OPC 40001-1 types MonitoringType/Status/Stacklight with the // OPC 10000-200 BasicStacklightType, so the full Machinery // model reaches into IA. The reduced Machinery copy this // sample used to carry had that edge stripped out. Opc.Ua.IA.Namespaces.IA, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { m_options = options?.Value ?? new GeneratorDeviceIntegrationOptions(); if (m_options.GeneratorCount is < 1 or > 100) { throw new ArgumentOutOfRangeException( $"{nameof(options)}.{nameof(GeneratorDeviceIntegrationOptions.GeneratorCount)}", m_options.GeneratorCount, "Generator count must be between 1 and 100."); } if (m_options.SimulationInterval <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException( $"{nameof(options)}.{nameof(GeneratorDeviceIntegrationOptions.SimulationInterval)}", m_options.SimulationInterval, "Simulation interval must be positive."); } } /// /// Gets the registered generator-set instance NodeIds. /// public ArrayOf GeneratorNodeIds { get { var nodeIds = new List(m_generatorSets.Count); foreach (GeneratorSetState set in m_generatorSets) { nodeIds.Add(set.NodeId); } return nodeIds.ToArrayOf(); } } /// /// Gets the interval the simulation loop ticks at. /// internal TimeSpan SimulationInterval => m_options.SimulationInterval; /// /// Gets whether the fault schedule is running. /// internal bool InjectFaults => m_options.InjectFaults; /// /// Creates and registers a generator set organised by the DI /// DeviceSet, wired into the running simulation. /// /// Browse name for the new set. /// Cancellation token. /// The registered generator set. public ValueTask CreateGeneratorSetAsync( QualifiedName browseName, CancellationToken cancellationToken = default) { return MaterialiseGeneratorSetAsync( browseName, m_generatorSets.Count + 1, cancellationToken, RegisterGeneratorSimulationAsync); } /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) { // Compose the predefined-node tree from the source-generated models in // dependency order. No runtime XML loading: the NodeSet2 XMLs ship only // as for the source generator. The generated AddOpcUa* // methods are idempotent and pull in their declared dependencies through // [ModelDependencyAttribute], so a direct chain is sufficient. var nodes = new NodeStateCollection(); nodes.AddOpcUaDi(context); // IA before Machinery: OPC 40001-1's Stacklight is typed by the // OPC 10000-200 BasicStacklightType, so the IA type nodes have to // be in the address space for that type definition to resolve. nodes.AddOpcUaIA(context); nodes.AddOpcUaMachinery(context); nodes.AddOpcUaGenerators(context); nodes.AddOpcUaOpenUsd(context); return new ValueTask(nodes); } /// protected override async ValueTask ConfigureAsync( INodeManagerBuilder builder, CancellationToken cancellationToken) { // Phase 1 (async): materialise the instances the fluent Configure pass // expects to find, so the builder has typed nodes to wire against. await ConfigureInstancesAsync(cancellationToken).ConfigureAwait(false); // Phase 2 (sync): wire the simulation, state machines and alarms. // The base DiNodeManager seals the builder once this returns. Configure(builder); m_logger.GeneratorAddressSpaceReady(PredefinedNodes.Count, m_generatorSets.Count); } /// /// Materialises the configured generator sets and the OpenUSD facility. /// /// Cancellation token. private async ValueTask ConfigureInstancesAsync(CancellationToken cancellationToken) { // The OpenUSD facility comes first so each set's representation has a // stage to reference. await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false); for (int number = 1; number <= m_options.GeneratorCount; number++) { var browseName = new QualifiedName( GetGeneratorBrowseName(number), InstanceNamespaceIndex); await MaterialiseGeneratorSetAsync(browseName, number, cancellationToken) .ConfigureAwait(false); } await OrganiseRepresentationsAsync(cancellationToken).ConfigureAwait(false); } /// /// Creates one generator set, materialises the optional subsystems the /// simulation drives, attaches its OpenUSD twin and registers the whole /// subtree. /// /// Browse name for the set. /// One-based unit number, used for the nameplate. /// Cancellation token. /// Invoked once the set is registered. /// The registered generator set. private async ValueTask MaterialiseGeneratorSetAsync( QualifiedName browseName, int setNumber, CancellationToken cancellationToken, Func? onRegistered = null) { IDeviceBuilder builder = await CreateDeviceAsync( browseName, NodeId.Create( Opc.Ua.Generators.ObjectTypes.GeneratorSetType, Opc.Ua.Generators.Namespaces.Generators, Server.NamespaceUris), parent => SystemContext.CreateInstanceOfGeneratorSetType(parent, browseName), parent: null, cancellationToken).ConfigureAwait(false); GeneratorSetState set = builder.Device; set.DisplayName = new LocalizedText(GetGeneratorDisplayName(setNumber)); MaterialiseOptionalChildren(set); MaterialiseProtectionAlarms(set); AttachOpenUsdRepresentation(set, setNumber); await AddPredefinedNodeAsync(SystemContext, set, cancellationToken) .ConfigureAwait(false); await AddRootNotifierAsync(set, cancellationToken).ConfigureAwait(false); // The nameplate is written after the subtree is registered. Each // identification property registers itself as it is created, so writing // beforehand lets the subsequent subtree registration replace those // nodes with freshly materialised, valueless ones - the properties then // browse but read BadNotReadable. WriteNameplate(builder, setNumber); if (onRegistered != null) { await onRegistered(set, cancellationToken).ConfigureAwait(false); } // Variables hand-built onto the set (rather than materialised by the // generated factory) browse and read correctly, but a monitored item // never samples them unless they are registered in their own right. await RegisterOpenUsdSignalsAsync(set, cancellationToken).ConfigureAwait(false); m_generatorSets.Add(set); m_logger.MaterialisedGeneratorSet(browseName.Name, set.NodeId); return set; } /// /// Writes the datasheet nameplate onto the DI identification properties. /// /// Device builder for the set. /// One-based unit number. /// /// Unit-level fields are derived from the set number so that /// --generators N yields a consistent nameplate per instance rather /// than N copies of the same serial number. /// private static void WriteNameplate(IDeviceBuilder builder, int setNumber) { builder.WithIdentification(id => { id.Manufacturer = new LocalizedText(GeneratorDatasheet.Identity.Manufacturer); id.ManufacturerUri = GeneratorDatasheet.Identity.ManufacturerUri; id.Model = new LocalizedText(GeneratorDatasheet.Identity.Model); id.ProductCode = GeneratorDatasheet.Identity.ProductCode; id.HardwareRevision = GeneratorDatasheet.Identity.HardwareRevision; id.SoftwareRevision = GeneratorDatasheet.Identity.SoftwareRevision; id.DeviceRevision = GeneratorDatasheet.Identity.DeviceRevision; id.SerialNumber = GetSerialNumber(setNumber); id.DeviceManual = "https://simgen.example.com/genx-500/manual"; id.ProductInstanceUri = FormattableString.Invariant($"urn:simgen.example.com:GenX-500:{GetSerialNumber(setNumber)}"); }); // Nameplate properties the generated factory already materialised keep // the declaration's access level, which grants no read. Only properties // the builder creates itself come out readable, so the ones that were // already there have to be opened explicitly or they browse but answer // BadNotReadable. GeneratorSetState set = builder.Device; MakeReadable( set.Manufacturer, set.ManufacturerUri, set.Model, set.ProductCode, set.HardwareRevision, set.SoftwareRevision, set.DeviceRevision, set.SerialNumber, set.DeviceManual, set.ProductInstanceUri, set.RevisionCounter); } /// /// Grants current-read access to variables that carry a static value. /// /// The variables to open for reading. private static void MakeReadable(params BaseVariableState?[] variables) { foreach (BaseVariableState? variable in variables) { if (variable == null) { continue; } variable.AccessLevel = AccessLevels.CurrentRead; variable.UserAccessLevel = AccessLevels.CurrentRead; variable.MinimumSamplingInterval = MinimumSamplingIntervals.Indeterminate; } } /// /// Materialises the optional GeneratorSetType children the simulation /// drives. Mandatory children - Identification, OperatingState, OperatingMode, /// Engine, Alternator and Controller - are created by the generated factory. /// /// The generator set to extend. private void MaterialiseOptionalChildren(GeneratorSetState set) { set.AddMachineryBuildingBlocks(SystemContext) .AddFuelSystem(SystemContext) .AddCoolingSystem(SystemContext) .AddLubricationSystem(SystemContext) .AddStartingSystem(SystemContext) .AddEmissionsStandard(SystemContext) .AddApplication(SystemContext) .AddGeneratorBreakerClosed(SystemContext) .AddGeneratorBreakerAvailable(SystemContext) .AddRemoteStartInput(SystemContext) .AddRunRequest(SystemContext) .AddLoadInhibit(SystemContext) .AddAvailableToLoad(SystemContext) .AddStart(SystemContext) .AddStop(SystemContext) .AddEmergencyStop(SystemContext) .AddResetFaults(SystemContext) .AddSetOperatingMode(SystemContext) .AddStartTest(SystemContext); MaterialiseSubsystemChildren(set); } /// /// Materialises the optional subsystem members the simulation publishes. /// /// The generator set to extend. /// /// The specification makes most telemetry optional so the type fits both a /// bare air-cooled residential set and a fully instrumented industrial one. /// This sample models the instrumented case, so it opts in explicitly rather /// than assuming the factory materialises them. /// private void MaterialiseSubsystemChildren(GeneratorSetState set) { set.Engine! .AddPercentLoad(SystemContext) .AddOilPressure(SystemContext) .AddOilTemperature(SystemContext) .AddCoolantTemperature(SystemContext) .AddFuelRate(SystemContext) .AddExhaustGasTemperature(SystemContext) .AddIntakeManifoldTemperature(SystemContext) .AddNumberOfStarts(SystemContext) .AddDisplacement(SystemContext) .AddCylinderCount(SystemContext) .AddRatedSpeed(SystemContext) .AddAspiration(SystemContext); // Three-phase set: L1 is mandatory, L2 and L3 are not. set.Alternator! .AddL2(SystemContext) .AddL3(SystemContext) .AddAverageLineToLineVoltage(SystemContext) .AddAverageLineToNeutralVoltage(SystemContext) .AddAverageCurrent(SystemContext) .AddTotalReactivePower(SystemContext) .AddTotalApparentPower(SystemContext) .AddAveragePowerFactor(SystemContext) .AddTotalRealEnergy(SystemContext) .AddLoadPercent(SystemContext) .AddWindingTemperature1(SystemContext) .AddConnection(SystemContext) .AddExcitationType(SystemContext) .AddNumberOfPoles(SystemContext); foreach (AlternatorPhaseState phase in new[] { set.Alternator!.L1!, set.Alternator!.L2!, set.Alternator!.L3!, }) { phase.AddLineToNeutralVoltage(SystemContext) .AddLineToLineVoltage(SystemContext) .AddRealPower(SystemContext) .AddReactivePower(SystemContext) .AddApparentPower(SystemContext) .AddPowerFactor(SystemContext); } set.FuelSystem! .AddFuelLevel(SystemContext) .AddFuelVolume(SystemContext) .AddFuelConsumptionRate(SystemContext) .AddRuntimeRemaining(SystemContext) .AddTotalFuelConsumed(SystemContext); set.CoolingSystem! .AddCoolantTemperature(SystemContext) .AddCoolantLevel(SystemContext) .AddAmbientTemperature(SystemContext) .AddCoolingMethod(SystemContext) .AddRadiatorFanRunning(SystemContext); set.LubricationSystem! .AddOilPressure(SystemContext) .AddOilTemperature(SystemContext) .AddOilLevel(SystemContext); set.StartingSystem! .AddBatteryChargingCurrent(SystemContext) .AddBatteryChargerActive(SystemContext) .AddStartAttempts(SystemContext); } /// /// Returns the browse name for a one-based generator set number. /// /// One-based unit number. /// The browse name. internal static string GetGeneratorBrowseName(int setNumber) { return FormattableString.Invariant($"GeneratorSet_{setNumber}"); } /// /// Returns the display name for a one-based generator set number. /// /// One-based unit number. /// The display name. internal static string GetGeneratorDisplayName(int setNumber) { return FormattableString.Invariant($"Generator Set {setNumber}"); } /// /// Returns the datasheet serial number for a one-based generator set number. /// /// One-based unit number. /// The serial number. internal static string GetSerialNumber(int setNumber) { return FormattableString.Invariant($"SG-500-{setNumber:D3}"); } } /// /// Creates the for the DI hosting pipeline. /// public sealed class GeneratorNodeManagerFactory : IAsyncNodeManagerFactory { private readonly IDiPostSetupRunner? m_postSetupRunner; private readonly IOptions? m_options; /// /// Initialises a factory without DI-hosting integration. /// public GeneratorNodeManagerFactory() { } /// /// Initialises a factory that participates in the DI hosting pipeline. /// /// The DI post-setup pipeline. /// Sample options. public GeneratorNodeManagerFactory( IDiPostSetupRunner? postSetupRunner, IOptions? options) { m_postSetupRunner = postSetupRunner; m_options = options; } /// public ArrayOf NamespacesUris => new string[] { Opc.Ua.Generators.Namespaces.Generators, Opc.Ua.Machinery.Namespaces.Machinery, Opc.Ua.OpenUsd.Namespaces.OpenUSD, }.ToArrayOf(); /// public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { #pragma warning disable CA2000 // ownership transferred to server IAsyncNodeManager nodeManager = new GeneratorNodeManager(server, configuration, m_postSetupRunner, m_options); #pragma warning restore CA2000 return new ValueTask(nodeManager); } } /// /// Source-generated log messages for . /// internal static partial class GeneratorNodeManagerLog { [LoggerMessage( EventId = GeneratorServerEventIds.GeneratorNodeManager + 0, Level = LogLevel.Information, Message = "Generator address space ready: {NodeCount} nodes, {SetCount} generator set(s).")] public static partial void GeneratorAddressSpaceReady( this ILogger logger, int nodeCount, int setCount); [LoggerMessage( EventId = GeneratorServerEventIds.GeneratorNodeManager + 1, Level = LogLevel.Information, Message = "Materialised generator set '{BrowseName}' as {NodeId}.")] public static partial void MaterialisedGeneratorSet( this ILogger logger, string? browseName, NodeId nodeId); } }