mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 16:52:07 +08:00
105508bcbd
# Description
Nodes created at runtime now get their NodeIds from a single
`DefaultNodeIdFactory` rather than from a `New` override per
NodeManager.
The factory derives a deterministic identifier from a canonical,
length-prefixed browse path, which is injective where the classic
`{parent}_{child}` concatenation was not — `A_B` + `C` and `A` + `B_C`
both produced `A_B_C` — and it also records the parent's identifier type
and qualifies cross-namespace segments by URI, so identifiers no longer
shift with namespace-table ordering.
A NodeManager selects its style declaratively instead of writing
assignment code:
```csharp
NodeIdFactory = NodeIdFactory.WithMode(NodeIdAssignmentMode.Counter);
```
`NodeIdAssignmentMode` offers `String` (the canonical path verbatim, the
default), `Numeric` / `Guid` / `Opaque` (that path through SHA-256),
`Counter` (sequential, for nodes whose browse paths repeat — per-session
diagnostics, inference jobs, rediscovered assets) and `None`. The
counter
starts above `0x40000000`, out of reach of any authored model, so a
long-running server cannot walk a runtime instance onto a type node.
`New` overrides are gone from every NodeManager the stack ships except
two whose identifiers are a domain scheme rather than an assignment
policy: `FileSystemNodeManager`, whose NodeIds encode the file path they
resolve back to, and `RoboticsNodeManager`, whose build coordinator
reserves identifiers across managers with ownership tracking.
**Namespace rule.** Every identifier is minted into the NodeManager's
own
namespace. Neither the parent's namespace nor the browse name's is
consulted: a parent can belong to a companion-specification model whose
NodeIds are fixed by its NodeSet (`DeviceSet` in the DI namespace), and
a
browse name only names the type that declared the child. NodeManagers
whose instance namespace is not their first one rebase with
`WithDefaultNamespaceIndex`.
**Types vs instances.** An authored NodeId on a node that stands on its
own is kept. A node hanging off a parent is re-minted, because it
reached
the factory through `AssignNodeIds` walking a subtree copied from a type
declaration. Keeping those would alias every instance onto the type's
own
nodes, and the predefined-node index takes the last writer, so the type
would quietly become an instance rather than the clash being reported.
New `ISystemContext.CreateInstance(...)` builds an instance of a
generated
type and rebases its subtree through `AssignInstanceNodeId` — the path
the
generated `CreateInstanceOf<Type>` helpers already took. The AI
NodeManager was the one place calling `NodeState.Create` directly, so
its
roots kept the NodeId their state object is born with (the type's own);
it now uses the shared path.
## Breaking changes
- `IAsyncNodeManager` extends `INodeIdFactory` and gains `AddNode` and
`AddRootNotifier`. This lets the fluent surface mint and register
through the interface instead of type-testing for
`AsyncCustomNodeManager` and silently falling back to a concatenated
identifier when the test failed — that fallback was itself a source of
divergent NodeIds.
- `AsyncNodeManagerAdapter` delegates `New` to the wrapped NodeManager,
so
**`CustomNodeManager2` behaves exactly as before**: it mints nothing and
a node keeps whatever NodeId it already has. Servers built on it are
unaffected.
## Notes for reviewers
- `docs/NodeIdAssignment.md` is new and describes the whole mechanism:
the
factory contract, identifier formats, type declarations vs instances,
the generated helpers and their parameters, per-NodeManager behaviour,
and an inventory of every member involved.
- The three commits are independently revertible.
- Two pieces that look redundant are deliberately kept, with the
reasoning
recorded in the doc: `assignInstanceNodeIds` (it *suppresses* minting,
where the rebase rule *forces* it) and the generator's
`NodeId.Equals(TypeNodeIdConstant)` guard (it distinguishes a
caller-assigned NodeId from a declaration's).
- Fluent builder tests previously passed only because of the removed
fallback; they now run against a real `DefaultNodeIdFactory` through a
shared `FluentTestNodeManager` helper.
## Related Issues
_No tracking issue yet._
## 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
> Test runs were on **net10.0 only** — .NET Framework was not exercised
> locally. Suites run green on an idle machine: Server 4950/4955, GDS
> 1116/1170, WotCon 1225/1225, Vision 484/485, DI 424/424, AI 174/174,
> ISA95 136/136, Positioning 69/69, RobotIntent 22/22. One unrelated
> pre-existing failure remains in `Opc.Ua.Types.Tests`
> (`EveryEmbeddedExampleMatchesItsRecordedHash`) — a WoT fixture checked
> out with CRLF, fixed by
> `git add --renormalize tests/Opc.Ua.Types.Tests/Wot/Assets`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
592 lines
26 KiB
C#
592 lines
26 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.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.Machinery;
|
|
using Opc.Ua.OpenUsd;
|
|
using Opc.Ua.Server;
|
|
using Opc.Ua.Server.Fluent;
|
|
using Opc.Ua.Server.NodeManager;
|
|
|
|
namespace Generators
|
|
{
|
|
/// <summary>
|
|
/// Runtime options for the generator-set device-integration sample.
|
|
/// </summary>
|
|
public sealed class GeneratorDeviceIntegrationOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets how many simulated generator sets are materialised.
|
|
/// </summary>
|
|
public int GeneratorCount { get; set; } = 2;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the interval used by the live simulation loop.
|
|
/// </summary>
|
|
public TimeSpan SimulationInterval { get; set; } = TimeSpan.FromMilliseconds(250);
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether one set develops faults on a slow rotation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public bool InjectFaults { get; set; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Node manager for the Generators companion-specification server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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
|
|
/// <c>GeneratorNodeManager.Configure.cs</c>; the OpenUSD twin lives in
|
|
/// <c>OpenUsdRepresentation.cs</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Unlike the pump sample - whose <c>PumpType</c> derives from the Machinery
|
|
/// <c>MachineType</c> and so cannot use the DI device helpers -
|
|
/// <c>GeneratorSetType</c> derives from the DI <c>DeviceType</c>, so sets are
|
|
/// created through <see cref="DiNodeManager.CreateDeviceAsync{TDevice}"/> and
|
|
/// get the DI nameplate, registration and topology wiring for free.
|
|
/// </para>
|
|
/// </remarks>
|
|
public partial class GeneratorNodeManager : DiNodeManager
|
|
{
|
|
private readonly GeneratorDeviceIntegrationOptions m_options;
|
|
private readonly List<GeneratorSetState> m_generatorSets = [];
|
|
|
|
/// <summary>
|
|
/// Wires the simulation, state machines and alarms against the
|
|
/// materialised instances. Implemented in
|
|
/// <c>GeneratorNodeManager.Configure.cs</c>.
|
|
/// </summary>
|
|
/// <param name="builder">The active fluent builder.</param>
|
|
partial void Configure(INodeManagerBuilder builder);
|
|
|
|
/// <summary>
|
|
/// Initialises a new <see cref="GeneratorNodeManager"/> without DI-hosting
|
|
/// integration.
|
|
/// </summary>
|
|
/// <param name="server">The server the manager belongs to.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
public GeneratorNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration)
|
|
: this(server, configuration, postSetupRunner: null, options: null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialises a new <see cref="GeneratorNodeManager"/> that participates in
|
|
/// the DI hosting post-setup pipeline.
|
|
/// </summary>
|
|
/// <param name="server">The server the manager belongs to.</param>
|
|
/// <param name="configuration">The application configuration.</param>
|
|
/// <param name="postSetupRunner">Optional DI post-setup pipeline.</param>
|
|
/// <param name="options">Optional sample options.</param>
|
|
/// <exception cref="ArgumentOutOfRangeException">
|
|
/// The configured generator count or simulation interval is out of range.
|
|
/// </exception>
|
|
public GeneratorNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
IDiPostSetupRunner? postSetupRunner,
|
|
IOptions<GeneratorDeviceIntegrationOptions>? options = null)
|
|
: base(
|
|
server,
|
|
configuration,
|
|
postSetupRunner,
|
|
Opc.Ua.Generators.Namespaces.Generators,
|
|
Opc.Ua.Machinery.Namespaces.Machinery,
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the registered generator-set instance NodeIds.
|
|
/// </summary>
|
|
public ArrayOf<NodeId> GeneratorNodeIds
|
|
{
|
|
get
|
|
{
|
|
var nodeIds = new List<NodeId>(m_generatorSets.Count);
|
|
foreach (GeneratorSetState set in m_generatorSets)
|
|
{
|
|
nodeIds.Add(set.NodeId);
|
|
}
|
|
return nodeIds.ToArrayOf();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the interval the simulation loop ticks at.
|
|
/// </summary>
|
|
internal TimeSpan SimulationInterval => m_options.SimulationInterval;
|
|
|
|
/// <summary>
|
|
/// Gets whether the fault schedule is running.
|
|
/// </summary>
|
|
internal bool InjectFaults => m_options.InjectFaults;
|
|
|
|
/// <summary>
|
|
/// Creates and registers a generator set organised by the DI
|
|
/// <c>DeviceSet</c>, wired into the running simulation.
|
|
/// </summary>
|
|
/// <param name="browseName">Browse name for the new set.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The registered generator set.</returns>
|
|
public ValueTask<GeneratorSetState> CreateGeneratorSetAsync(
|
|
QualifiedName browseName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return MaterialiseGeneratorSetAsync(
|
|
browseName,
|
|
m_generatorSets.Count + 1,
|
|
cancellationToken,
|
|
RegisterGeneratorSimulationAsync);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
protected override ValueTask<NodeStateCollection> 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 <AdditionalFiles> 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);
|
|
nodes.AddOpcUaMachinery(context);
|
|
nodes.AddOpcUaGenerators(context);
|
|
nodes.AddOpcUaOpenUsd(context);
|
|
return new ValueTask<NodeStateCollection>(nodes);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materialises the configured generator sets and the OpenUSD facility.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates one generator set, materialises the optional subsystems the
|
|
/// simulation drives, attaches its OpenUSD twin and registers the whole
|
|
/// subtree.
|
|
/// </summary>
|
|
/// <param name="browseName">Browse name for the set.</param>
|
|
/// <param name="setNumber">One-based unit number, used for the nameplate.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <param name="onRegistered">Invoked once the set is registered.</param>
|
|
/// <returns>The registered generator set.</returns>
|
|
private async ValueTask<GeneratorSetState> MaterialiseGeneratorSetAsync(
|
|
QualifiedName browseName,
|
|
int setNumber,
|
|
CancellationToken cancellationToken,
|
|
Func<GeneratorSetState, CancellationToken, ValueTask>? onRegistered = null)
|
|
{
|
|
IDeviceBuilder<GeneratorSetState> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the datasheet nameplate onto the DI identification properties.
|
|
/// </summary>
|
|
/// <param name="builder">Device builder for the set.</param>
|
|
/// <param name="setNumber">One-based unit number.</param>
|
|
/// <remarks>
|
|
/// Unit-level fields are derived from the set number so that
|
|
/// <c>--generators N</c> yields a consistent nameplate per instance rather
|
|
/// than N copies of the same serial number.
|
|
/// </remarks>
|
|
private static void WriteNameplate(IDeviceBuilder<GeneratorSetState> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Grants current-read access to variables that carry a static value.
|
|
/// </summary>
|
|
/// <param name="variables">The variables to open for reading.</param>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materialises the optional <c>GeneratorSetType</c> children the simulation
|
|
/// drives. Mandatory children - Identification, OperatingState, OperatingMode,
|
|
/// Engine, Alternator and Controller - are created by the generated factory.
|
|
/// </summary>
|
|
/// <param name="set">The generator set to extend.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materialises the optional subsystem members the simulation publishes.
|
|
/// </summary>
|
|
/// <param name="set">The generator set to extend.</param>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the browse name for a one-based generator set number.
|
|
/// </summary>
|
|
/// <param name="setNumber">One-based unit number.</param>
|
|
/// <returns>The browse name.</returns>
|
|
internal static string GetGeneratorBrowseName(int setNumber)
|
|
{
|
|
return FormattableString.Invariant($"GeneratorSet_{setNumber}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the display name for a one-based generator set number.
|
|
/// </summary>
|
|
/// <param name="setNumber">One-based unit number.</param>
|
|
/// <returns>The display name.</returns>
|
|
internal static string GetGeneratorDisplayName(int setNumber)
|
|
{
|
|
return FormattableString.Invariant($"Generator Set {setNumber}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the datasheet serial number for a one-based generator set number.
|
|
/// </summary>
|
|
/// <param name="setNumber">One-based unit number.</param>
|
|
/// <returns>The serial number.</returns>
|
|
internal static string GetSerialNumber(int setNumber)
|
|
{
|
|
return FormattableString.Invariant($"SG-500-{setNumber:D3}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the <see cref="GeneratorNodeManager"/> for the DI hosting pipeline.
|
|
/// </summary>
|
|
public sealed class GeneratorNodeManagerFactory : IAsyncNodeManagerFactory
|
|
{
|
|
private readonly IDiPostSetupRunner? m_postSetupRunner;
|
|
private readonly IOptions<GeneratorDeviceIntegrationOptions>? m_options;
|
|
|
|
/// <summary>
|
|
/// Initialises a factory without DI-hosting integration.
|
|
/// </summary>
|
|
public GeneratorNodeManagerFactory()
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialises a factory that participates in the DI hosting pipeline.
|
|
/// </summary>
|
|
/// <param name="postSetupRunner">The DI post-setup pipeline.</param>
|
|
/// <param name="options">Sample options.</param>
|
|
public GeneratorNodeManagerFactory(
|
|
IDiPostSetupRunner? postSetupRunner,
|
|
IOptions<GeneratorDeviceIntegrationOptions>? options)
|
|
{
|
|
m_postSetupRunner = postSetupRunner;
|
|
m_options = options;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ArrayOf<string> NamespacesUris => new string[]
|
|
{
|
|
Opc.Ua.Generators.Namespaces.Generators,
|
|
Opc.Ua.Machinery.Namespaces.Machinery,
|
|
Opc.Ua.OpenUsd.Namespaces.OpenUSD,
|
|
}.ToArrayOf();
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask<IAsyncNodeManager> 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<IAsyncNodeManager>(nodeManager);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Source-generated log messages for <see cref="GeneratorNodeManager"/>.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|