diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml index 17630122..dbd7b027 100644 --- a/.github/workflows/dotnet-ci.yml +++ b/.github/workflows/dotnet-ci.yml @@ -66,6 +66,51 @@ jobs: Tee-Object -FilePath artifacts/test-results/dotnet-test.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Package test build + if: success() + shell: pwsh + run: | + $root = "artifacts/ariec61850-test-build" + $binary = Join-Path $root "lib/net8.0" + $nuget = Join-Path $root "nuget" + $builtBinary = ".\source\.artifacts\bin\AR.Iec61850\Release\net8.0" + New-Item -ItemType Directory -Path $binary -Force | Out-Null + New-Item -ItemType Directory -Path $nuget -Force | Out-Null + + if (-not (Test-Path $builtBinary)) { + throw "Expected Release output was not found at $builtBinary" + } + Copy-Item (Join-Path $builtBinary "*") $binary -Recurse -Force + + $packageVersion = "0.0.0-pr134.$env:GITHUB_RUN_NUMBER" + dotnet pack .\source\src\AR.Iec61850\AR.Iec61850.csproj ` + -c Release ` + --no-build ` + -o $nuget ` + -p:PackageVersion=$packageVersion + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + @( + "ARIEC61850 PR #134 smart-discovery test build", + "Commit: $env:GITHUB_SHA", + "Branch: $env:GITHUB_HEAD_REF", + "Run: $env:GITHUB_RUN_NUMBER", + "TargetFramework: net8.0", + "NuGetVersion: $packageVersion", + "", + "lib/net8.0 contains the direct Release build for DLL-reference testing.", + "nuget contains the installable local .nupkg for PackageReference testing." + ) | Set-Content -Path (Join-Path $root "BUILD.txt") -Encoding utf8 + + - name: Upload PR test build + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ariec61850-pr134-test-build + path: artifacts/ariec61850-test-build + if-no-files-found: error + retention-days: 14 + - name: Upload test diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ddb57cb..16cb5c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable public changes to ARIEC61850 are recorded here. The project is still ### Added +- Added zero-traffic smart-discovery KPI evidence across structural `GetNameList`, DataSet directory, hierarchy GVA, and bounded FC-root initial Read phases, including per-phase latency, duplicate semantic-request detection, peak outstanding requests, canonical completeness counters, deterministic repeat-run signatures, and explicit partial wire-accounting status for unobserved enrichment paths. - Added live-authoritative SCL ReportControl family reconciliation for static reporting: indexed declarative controls are matched only to concrete live MMS RCB instances in the same domain/logical-node/report-FC with decimal instance suffixes, while non-indexed controls remain exact-name only and no runtime RCB name is synthesized. - Added an SCL-aware static report planner that scopes the existing safe RCB selector to the reconciled live family, allowing a busy first instance to fall through to another proven-free instance without escaping to an unrelated control block. - Added registered-monitor initial GI bootstrap for persistent reporting: the monitor is installed before the one-shot GI request, initial mapped DataSet reports are returned explicitly, and GI is attempted only when the live RCB attribute inventory proves the field exists. diff --git a/docs/SMART_DISCOVERY_KPI.md b/docs/SMART_DISCOVERY_KPI.md new file mode 100644 index 00000000..0ed7da86 --- /dev/null +++ b/docs/SMART_DISCOVERY_KPI.md @@ -0,0 +1,140 @@ +# Smart Discovery KPI / Capture Convergence + +Status: **implemented + unit tested; live capture convergence not yet re-validated after this patch**. + +This document defines the P0-4 observability contract for smart IEC 61850 discovery. The KPI layer is diagnostic evidence only. It does not create a second IEC 61850 model, does not alter canonical semantics, and does not add MMS traffic. + +## Architecture + +```text +existing smart discovery requests + | + +-- GetNameList + +-- GetNamedVariableListAttributes + +-- GetVariableAccessAttributes + `-- bounded FC-root Read + | + v +existing confirmed-service execution + | + +------------------------+ + | | + v v +canonical discovery/model zero-traffic KPI recorder + counts / latency / duplicate keys + peak outstanding / completeness + deterministic signature +``` + +The canonical IED model remains the only semantic source of truth. KPI fields are an evidence overlay and are never used to manufacture LD/LN/DO/DA, DataSet, RCB, value, type, or SCL semantics. + +## Zero-traffic invariant + +KPI collection must not issue any additional: + +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `Read`; +- `GetNamedVariableListAttributes`; +- association request; +- report/control request; +- transport probe. + +Observations are opened only around confirmed requests that the smart path already intends to send. + +## Observed phases + +The optimized default path records these wire request classes: + +| Phase | MMS service | Logical duplicate key | +|---|---|---| +| `structure` | `GetNameList` | object class + domain/VMD + continuation token | +| `dataset-directory` | `GetNamedVariableListAttributes` | exact DataSet reference | +| `type-enrichment` | `GetVariableAccessAttributes` | exact domain/item reference | +| `initial-read` | `Read` | ordered FC-root reference batch | + +A repeated normalized logical key in one generation increments `DuplicateRequests`. Legitimate GetNameList continuation pages are distinct because the continuation token is part of the key. + +## Snapshot contract + +`MmsClientSession.LastSmartDiscoveryKpi` exposes a point-in-time `MmsSmartDiscoveryKpiSnapshot` containing: + +- total / successful / failed observed confirmed requests; +- duplicate request count; +- peak outstanding request count; +- per-phase request count and latency statistics; +- LD, LN, raw-variable and FC-point completeness; +- DataSet / DataSet-directory / ordered member completeness; +- RCB / BRCB / URCB counts; +- `WireAccountingComplete` plus explicit `AccountingNotes`; +- deterministic signature. + +`RefreshSmartDiscoveryModelKpi(directory)` can refresh LD/LN/FC-point completeness after later semantic materialization. It is local bookkeeping and performs no network I/O. + +## Deterministic signature + +The signature intentionally excludes: + +- wall-clock timestamps; +- measured request latency; +- worker completion order; +- invoke ID allocation order. + +It includes normalized request identity/attempt outcomes, accounting coverage, and semantic completeness counts. Equivalent evidence should therefore produce the same signature even when concurrent workers finish in a different order. + +This signature is a convergence diagnostic, not an IEC 61850 semantic fingerprint and not an SCL identity. + +## Accounting coverage + +The default optimized smart-discovery path keeps report attribute probing off the structural critical path. For that path, all currently instrumented smart request loops can report complete wire accounting. + +If `ProbeReportAttributes=true` and report controls are eligible for probing, the current legacy report-enrichment Read fallback path is not yet observed at individual wire-attempt granularity. The snapshot therefore sets: + +```text +WireAccountingComplete = false +``` + +and adds an explicit accounting note. This prevents an undercount from being presented as complete evidence. + +## Current controlled convergence target + +For the current controlled regression device/session, the project-level target supplied to P0 is: + +```text +LD 32 +LN 119 +semantic leaves 4,925 +DataSet 2 +ordered FCDA 58 +logical ReportControl 32 +``` + +These values are capture-specific regression evidence, not IEC 61850 limits and must never be hard-coded into protocol behavior. + +P0-4 live acceptance requires a new controlled run to demonstrate, for the same evidence set: + +1. expected canonical completeness is retained; +2. `DuplicateRequests == 0` unless a documented retry/fallback is expected; +3. `WireAccountingComplete == true` for the optimized default path; +4. repeated runs yield the same deterministic signature when semantic evidence and outcomes are unchanged; +5. request count and per-phase latency are recorded without additional MMS traffic; +6. DataSet member order and RCB semantics remain unchanged. + +## What is validated now + +- **Implemented:** zero-traffic KPI recorder and smart call-site observations. +- **Unit tested:** deterministic signature independent of completion order; duplicate detection; peak outstanding count; model-completeness refresh; explicit partial accounting. +- **CI validated:** build/test/source-verification must pass on the exact PR head before this phase is called code-complete. +- **Not yet laboratory re-validated:** final request counts, latency distribution, duplicate count, and canonical completeness against a fresh controlled packet capture after this patch. + +## Next phase: P0-5 Request Budget & Adaptive Convergence + +P0-5 may consume P0-4 evidence to reduce redundant requests and tune bounded concurrency. It must not optimize from guesses. Any request removal or concurrency change must preserve canonical completeness, DataSet ordering, report/control safety, deterministic publication order, and negotiated `maxOutstandingCalling` limits. + +P0-5 candidate gates: + +- establish per-phase request budget from controlled repeat runs; +- identify only proven duplicate/redundant semantic requests; +- adapt window conservatively from negotiated peer limits and observed latency/failure evidence; +- compare before/after request count and wall time with identical canonical signature/completeness; +- fall back to the conservative path when evidence is insufficient. diff --git a/docs/SMART_MMS_DISCOVERY.md b/docs/SMART_MMS_DISCOVERY.md new file mode 100644 index 00000000..1fbe3ca8 --- /dev/null +++ b/docs/SMART_MMS_DISCOVERY.md @@ -0,0 +1,137 @@ +# Smart MMS discovery + +`MmsClientSession.DiscoverSmartAsync` is the capture-informed discovery path for fast, deterministic IEC 61850 model construction. It follows the repository's canonical contract: live MMS provides online evidence, SCL may prioritize and validate that evidence, and both feed the same canonical model rather than separate semantic trees. + +## Design invariants + +- One TCP/COTP/MMS association and one receive pump per session. +- Confirmed responses are correlated by invoke ID; discovery never starts a second raw receive loop. +- The VMD domain list is enumerated once per smart discovery operation. +- Independent `(domain, object-class)` GetNameList chains may be outstanding concurrently. +- Continuation pages inside one GetNameList chain are always sequential. +- The effective discovery window is capped by the peer's negotiated `maxOutstandingCalling` when available. Unknown peers use a conservative fallback cap. +- A fixed-size worker pool is used instead of creating one Task per domain/service pair, keeping scheduling and allocation bounded on large IEDs. +- Published dictionaries are rebuilt from live evidence in deterministic domain order, so concurrent completion and SCL priority hints never change model semantics. +- Valid evidence from completed pages/chains is retained when a later page or another chain fails. +- Expected receive-pump/association loss during concurrent work becomes a partial result instead of an unhandled `Task.WhenAll` failure. +- Initial smart discovery does not perform an eager per-leaf Read or GetVariableAccessAttributes sweep. +- Report attribute reads and DataSet directory reads are deferred by default because they are enrichment, not prerequisites for the first usable LD/LN/DO/DA tree. + +## Bounded pagination + +The smart pager is intentionally separate from the compatibility pager. It uses an O(n) `HashSet` boundary de-duplication path and has explicit guards for: + +- maximum pages per GetNameList chain; +- maximum names per chain; +- no-new-name pages while `moreFollows` remains true; +- empty continuation tokens; +- repeated continuation tokens/cycles. + +If a guard fires, already-decoded names remain available and the chain is marked incomplete. The engine does not keep asking the same IED question indefinitely. + +## Type enrichment + +Canonical type enrichment is planned by `LiveIedVariableTypeProbePlanner` and executed by `LiveIedVariableTypeProbeExecutor.ProbeSmartAsync`. + +The adaptive ladder is: + +1. Probe each MMS logical-node root (`LN`) once. +2. Use the returned `TypeSpecification` tree to prove every descendant path that is actually present in that hierarchy. +3. Only unresolved branches fall back to distinct `LN$FC$DO` roots. +4. Only descendants still unresolved after the DO-root result fall back to exact leaf GVA. + +This is deliberately coverage-aware: a successful but shallow parent response does not suppress required child probes. Normal structured IEC 61850 servers therefore approach one GVA per logical node, while unusual servers still retain an exact fallback path. + +Type-tree projection into the canonical live model is indexed by MMS Domain/LogicalNode before results are mapped. A type result therefore scans only the relevant LN instead of repeatedly scanning every point in the IED. + +## Initial value reads + +Initial values use the existing canonical/SCL/live `InitialFcReadPlan`; the smart path does not invent a second read model. The planner groups leaf points into FC roots such as `XCBR1$ST` or `MMXU1$MX` and batches up to the bounded MMS request limit. + +`ExecuteInitialFcReadPlanSmartAsync` changes only the scheduling layer: + +- default maximum four outstanding Read batches when association limits are known; +- conservative two-batch window when `maxOutstandingCalling` is unknown; +- always capped by the negotiated association limit; +- fixed worker count instead of Task-per-batch; +- invoke-ID response correlation remains authoritative; +- per-batch timeout remains explicit; +- one timeout/transport fault resets the association once so late responses cannot become stale evidence; +- completed batches and projections are preserved when another batch fails; +- no automatic retry/split storm and no fallback to thousands of leaf Reads. + +Example: + +```csharp +var initialPlan = InitialFcReadPlanner.FromLiveDirectory(discovery.IedDirectory); +var initialValues = await session.ExecuteInitialFcReadPlanSmartAsync( + initialPlan, + new MmsSmartInitialFcReadOptions + { + MaxOutstandingBatches = 4 + }, + cancellationToken); +``` + +For an opened SCL model, use the canonical SCL initial-read planner already provided by the engine and execute that same plan through the smart executor. + +## SCL-assisted scheduling + +SCL is a scheduling/validation hint, never a substitute for online evidence. `MmsSmartDiscoveryOptions.PriorityDomains` can be populated from the exact expected MMS domains of the trusted SCL IED/AP/Server selection. The engine still performs live VMD `GetNameList`, keeps extra live domains, and publishes the live-selected domain set unchanged. + +```csharp +var options = new MmsSmartDiscoveryOptions +{ + MaxConcurrentChains = 8, + // Optional, when trusted SCL context is already selected: + PriorityDomains = sclDomainInventory?.ExpectedDomains ?? Array.Empty() +}; + +var discovery = await session.DiscoverSmartAsync(options, cancellationToken); + +var exactTypes = await LiveIedVariableTypeProbeExecutor.ProbeSmartAsync( + session, + discovery.IedDirectory, + options, + cancellationToken); +``` + +Expensive runtime enrichment can be requested explicitly when needed: + +```csharp +var deepOptions = new MmsSmartDiscoveryOptions +{ + MaxConcurrentChains = 8, + ProbeReportAttributes = true, + ReadDataSetDirectories = true +}; +``` + +The legacy `DiscoverAsync`, `GetVariableAccessAttributesBatchAsync`, and sequential `ExecuteInitialFcReadPlanAsync` APIs remain unchanged for compatibility. Consumers can migrate deliberately and compare model completeness before making smart paths their default. + +## Transport safety + +Pipelining requires multiple confirmed requests to be outstanding. `TpktClient` therefore serializes writers around each complete TPKT frame. The frame is allocated inside that single-writer gate, keeping peak outbound-frame allocation bounded while still allowing multiple request/response lifecycles to remain outstanding. The existing single receive pump remains the only association reader. + +## Consumer anti-lag rule + +The engine smart methods use asynchronous I/O and `ConfigureAwait(false)` internally, so MMS waits, response decoding, and canonical model materialization do not require the caller's UI synchronization context. A UI consumer should publish immutable snapshots/results back to the UI in coarse batches rather than adding thousands of tree nodes one by one. Discovery/type/value phases are intentionally separate so the first structural model can be shown before optional metadata/value enrichment finishes. + +## Capture-informed target + +The reference capture used during this refactor showed the existing consumer issuing roughly 30.7k confirmed MMS requests, including roughly 23.7k GetVariableAccessAttributes and 6.6k Read requests, while the comparison tool used a much smaller, pipelined request set. These values are benchmark evidence, not protocol requirements. The smart path targets the independently observed scheduling pattern—single association, bounded outstanding requests, structural discovery first, hierarchy-aware metadata, FC-root selective reads—without copying vendor code or vendor-specific implementation details. + +For acceptance, compare the same IED and capture conditions using: + +- time to first visible model item; +- time to usable LD/LN/DO/DA tree; +- final LD/LN/FC-point and dataset counts; +- confirmed request count by service; +- peak outstanding confirmed requests; +- failed/partial domain chains; +- exact type coverage after smart enrichment; +- FC-root initial-value coverage and Read request count; +- managed allocations and task count during discovery; +- UI-thread stalls / long frames in the consuming application. + +A performance result is accepted only when the final model remains semantically equivalent for the required scope. diff --git a/src/AR.Iec61850/Control/Iec61850ControlService.cs b/src/AR.Iec61850/Control/Iec61850ControlService.cs index e2099f3e..30b4051f 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlService.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlService.cs @@ -17,12 +17,40 @@ public Task OpenAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(session); - return OpenCoreAsync(new MmsClientControlTransport(session), objectReference, cancellationToken); + return OpenCoreAsync(new MmsClientControlTransport(session), objectReference, null, cancellationToken); } + /// + /// Opens a control object while reusing an authoritative name inventory already + /// discovered on the same MMS association. The supplied inventory is structural + /// evidence only: ctlModel, exact control type specifications, timeout values and + /// status values remain live reads/probes so command safety stays fail-closed. + /// + public Task OpenAsync( + MmsClientSession session, + string objectReference, + IReadOnlyDictionary> authoritativeDomainVariables, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(authoritativeDomainVariables); + return OpenCoreAsync( + new MmsClientControlTransport(session), + objectReference, + authoritativeDomainVariables, + cancellationToken); + } + + internal Task OpenCoreAsync( + IIec61850ControlTransport transport, + string objectReference, + CancellationToken cancellationToken = default) + => OpenCoreAsync(transport, objectReference, null, cancellationToken); + internal async Task OpenCoreAsync( IIec61850ControlTransport transport, string objectReference, + IReadOnlyDictionary>? authoritativeDomainVariables, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(transport); @@ -65,7 +93,18 @@ internal async Task OpenCoreAsync( evidence.Add($"Cancel={cancelSpecification.Signature}"); } - var namesByDomain = await transport.DiscoverDomainVariablesAsync(cancellationToken).ConfigureAwait(false); + IReadOnlyDictionary> namesByDomain; + if (authoritativeDomainVariables != null) + { + namesByDomain = authoritativeDomainVariables; + evidence.Add("domainInventory=authoritative-reuse"); + } + else + { + namesByDomain = await transport.DiscoverDomainVariablesAsync(cancellationToken).ConfigureAwait(false); + evidence.Add("domainInventory=live-fallback"); + } + namesByDomain.TryGetValue(references.Domain, out var domainNames); domainNames ??= Array.Empty(); diff --git a/src/AR.Iec61850/Control/Iec61850ControlTransport.cs b/src/AR.Iec61850/Control/Iec61850ControlTransport.cs index 31e42f47..b323d819 100644 --- a/src/AR.Iec61850/Control/Iec61850ControlTransport.cs +++ b/src/AR.Iec61850/Control/Iec61850ControlTransport.cs @@ -41,7 +41,7 @@ public Task GetVariableSpecificationAsync(Mms => _session.GetVariableAccessAttributesAsync(reference, cancellationToken); public Task>> DiscoverDomainVariablesAsync(CancellationToken cancellationToken) - => _session.DiscoverDomainVariableNamesAsync(cancellationToken); + => _session.GetAuthoritativeDomainVariableNamesAsync(cancellationToken); public IAsyncDisposable SubscribeInformationReports(out ChannelReader reader, int capacity = 32) { diff --git a/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs new file mode 100644 index 00000000..f9cdd4c1 --- /dev/null +++ b/src/AR.Iec61850/Discovery/LiveIedCanonicalModel.cs @@ -0,0 +1,197 @@ +using AR.Iec61850.Mms; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Discovery; + +/// +/// Canonical live IED snapshot used for interoperable export. The discovery tree, +/// accepted communication evidence, and exact initial instance-value evidence are kept +/// together so exporters do not have to invent connection or engineering values. +/// +public sealed class LiveIedCanonicalModel +{ + public string SchemaVersion { get; init; } = "live-ied-canonical-v3"; + public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public LiveIedModelDiscoveryDocument Discovery { get; init; } = new(); + public LiveIedCommunicationEvidence Communication { get; init; } = new(); + public IReadOnlyList InstanceValues { get; init; } = + Array.Empty(); + + public string IedName => Discovery.IedName; + public string AccessPointName => string.IsNullOrWhiteSpace(Communication.AccessPointName) + ? Discovery.AccessPointName + : Communication.AccessPointName; +} + +/// +/// One scalar leaf observed through an exact FC-root projection. Domain/LN/DO/path are +/// explicit so SCL instance data can be materialized without reparsing display strings. +/// +public sealed class LiveIedInstanceValueEvidence +{ + public string Domain { get; init; } = string.Empty; + public string LogicalNode { get; init; } = string.Empty; + public string DataObject { get; init; } = string.Empty; + public string AttributePath { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; + public string SclBType { get; init; } = string.Empty; + public MmsDataValue Value { get; init; } = null!; + public string Source { get; init; } = "InitialFcRootRead"; + + public string Reference => + string.IsNullOrWhiteSpace(Domain) || string.IsNullOrWhiteSpace(LogicalNode) + ? string.Empty + : $"{Domain}/{LogicalNode}.{DataObject}.{AttributePath}".TrimEnd('.'); +} + +/// +/// Communication evidence bound to the exact accepted association used for discovery. +/// Empty fields mean unknown; they must not be replaced by device-specific guesses. +/// +public sealed class LiveIedCommunicationEvidence +{ + public string Source { get; init; } = string.Empty; + public string AssociationProfileName { get; init; } = string.Empty; + public string Host { get; init; } = string.Empty; + public int Port { get; init; } = 102; + public string AccessPointName { get; init; } = "AP1"; + public string SubNetworkName { get; init; } = "StationBus"; + public string IpSubnet { get; init; } = string.Empty; + public string IpGateway { get; init; } = string.Empty; + public SclIsoAssociationAddress Association { get; init; } = new(); + + public bool HasInteroperableSclAssociation => + !string.IsNullOrWhiteSpace(Host) && + !string.IsNullOrWhiteSpace(Association.ApTitle) && + Association.AeQualifier is >= 0 and <= 65535 && + !string.IsNullOrWhiteSpace(Association.PresentationSelector) && + !string.IsNullOrWhiteSpace(Association.SessionSelector) && + !string.IsNullOrWhiteSpace(Association.TransportSelector); +} + +public static class LiveIedCanonicalModelBuilder +{ + public static LiveIedCanonicalModel Build( + LiveIedModelDiscoveryDocument discovery, + LiveIedCommunicationEvidence communication, + InitialFcReadExecutionResult? initialRead = null) + { + ArgumentNullException.ThrowIfNull(discovery); + ArgumentNullException.ThrowIfNull(communication); + + if (!string.IsNullOrWhiteSpace(discovery.Host) && + !string.IsNullOrWhiteSpace(communication.Host) && + !string.Equals(discovery.Host.Trim(), communication.Host.Trim(), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Discovery host '{discovery.Host}' does not match accepted-association host '{communication.Host}'."); + } + + return new LiveIedCanonicalModel + { + Discovery = discovery, + Communication = communication, + InstanceValues = BuildInstanceValues(initialRead), + GeneratedAtUtc = discovery.GeneratedAtUtc + }; + } + + private static IReadOnlyList BuildInstanceValues( + InitialFcReadExecutionResult? initialRead) + { + if (initialRead is null || initialRead.Batches.Count == 0) + return Array.Empty(); + + var values = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var batch in initialRead.Batches.OrderBy(batch => batch.BatchIndex)) + { + foreach (var projection in batch.Projections) + { + var domain = projection.Target.Domain?.Trim() ?? string.Empty; + var logicalNode = projection.Target.LogicalNode?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(logicalNode)) + continue; + + foreach (var leaf in projection.Leaves) + { + if (leaf.Value is null || + leaf.Value.Kind is MmsDataKind.Structure or MmsDataKind.Array or MmsDataKind.Unknown) + { + continue; + } + + if (!TryResolveDataObject( + domain, + logicalNode, + leaf.Reference, + out var dataObject)) + { + continue; + } + + var attributePath = leaf.AttributePath?.Trim().Trim('.') ?? string.Empty; + if (string.IsNullOrWhiteSpace(dataObject) || + string.IsNullOrWhiteSpace(attributePath)) + { + continue; + } + + var functionalConstraint = (leaf.FunctionalConstraint ?? string.Empty) + .Trim() + .ToUpperInvariant(); + var key = string.Concat( + domain, "", + logicalNode, "", + dataObject, "", + attributePath, "", + functionalConstraint); + if (!seen.Add(key)) + continue; + + values.Add(new LiveIedInstanceValueEvidence + { + Domain = domain, + LogicalNode = logicalNode, + DataObject = dataObject, + AttributePath = attributePath, + FunctionalConstraint = functionalConstraint, + SclBType = leaf.SclBType?.Trim() ?? string.Empty, + Value = leaf.Value, + Source = "InitialFcRootRead" + }); + } + } + } + + return values + .OrderBy(value => value.Domain, StringComparer.Ordinal) + .ThenBy(value => value.LogicalNode, StringComparer.Ordinal) + .ThenBy(value => value.DataObject, StringComparer.Ordinal) + .ThenBy(value => value.AttributePath, StringComparer.Ordinal) + .ThenBy(value => value.FunctionalConstraint, StringComparer.Ordinal) + .ToArray(); + } + + private static bool TryResolveDataObject( + string domain, + string logicalNode, + string? reference, + out string dataObject) + { + dataObject = string.Empty; + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + if (string.IsNullOrWhiteSpace(text)) + return false; + + var prefix = $"{domain}/{logicalNode}."; + if (!text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var remainder = text[prefix.Length..].Trim('.'); + var separator = remainder.IndexOf('.'); + dataObject = (separator < 0 ? remainder : remainder[..separator]).Trim(); + return !string.IsNullOrWhiteSpace(dataObject); + } +} diff --git a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs index c8863aff..60460fff 100644 --- a/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs +++ b/src/AR.Iec61850/Discovery/LiveIedVariableTypeHierarchy.cs @@ -23,8 +23,144 @@ public static IReadOnlyList BuildLogicalNodeRootCandidates(M } } +/// +/// Canonical live-discovery entry point for bounded type enrichment. Planning stays +/// in the Discovery layer while the MMS session only executes the supplied evidence +/// probes. This prevents SCL/live semantic logic from leaking into the wire layer. +/// +public static class LiveIedVariableTypeProbeExecutor +{ + public static Task> ProbeSmartAsync( + MmsClientSession session, + MmsIedModelDirectory directory, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(directory); + + var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(directory); + return session.GetVariableAccessAttributesSmartAsync( + directory, + roots, + options, + cancellationToken); + } +} + +/// +/// Materializes named IEC 61850 leaf paths that are present in an authoritative +/// logical-node TypeSpecification but absent from the flat GetNameList inventory. +/// Existing live GetNameList/DataSet points always win; this only fills proven gaps. +/// +internal static class LiveIedTypeHierarchyPointMaterializer +{ + private static readonly HashSet FunctionalConstraints = new( + [ + "ST", "MX", "SP", "SV", "CF", "DC", "SG", "SE", "SR", "OR", + "BL", "EX", "CO", "RP", "BR", "LG", "GO", "GS", "MS", "US" + ], + StringComparer.OrdinalIgnoreCase); + + public static int Augment( + MmsIedModelDirectory directory, + IReadOnlyList results) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(results); + + if (directory.PointCount == 0 || results.Count == 0) + return 0; + + var supplemental = new List(); + + foreach (var result in results.Where(result => + result.IsSuccess && + result.TypeSpecification is not null && + !string.IsNullOrWhiteSpace(result.Reference.Domain) && + !string.IsNullOrWhiteSpace(result.Reference.Item) && + !result.Reference.Item.Contains('$'))) + { + var domain = result.Reference.Domain.Trim(); + var logicalNode = result.Reference.Item.Trim(); + if (!directory.LogicalDevices.TryGetValue(domain, out var logicalDevice) || + !logicalDevice.LogicalNodes.ContainsKey(logicalNode)) + { + continue; + } + + foreach (var fcNode in result.TypeSpecification!.Children) + { + var functionalConstraint = NormalizeFunctionalConstraint(fcNode.Name); + if (string.IsNullOrWhiteSpace(functionalConstraint)) + continue; + + foreach (var child in fcNode.Children) + Visit(child, functionalConstraint, []); + } + + void Visit( + MmsTypeSpecificationNode node, + string functionalConstraint, + IReadOnlyList parentPath) + { + var name = (node.Name ?? string.Empty).Trim(); + if (!IsUsableComponentName(name) || + string.Equals(node.MmsType, "array", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var path = new string[parentPath.Count + 1]; + for (var index = 0; index < parentPath.Count; index++) + path[index] = parentPath[index]; + path[^1] = name; + + if (node.Children.Count > 0) + { + foreach (var child in node.Children) + Visit(child, functionalConstraint, path); + return; + } + + // An IEC 61850 leaf below an FC must contain at least DO + DA. + if (path.Length < 2) + return; + + supplemental.Add(new MmsFcResolvedPoint + { + Domain = domain, + LogicalNode = logicalNode, + FunctionalConstraint = functionalConstraint, + DataObjectPath = string.Join(".", path), + MmsItemName = $"{logicalNode}${functionalConstraint}${string.Join("$", path)}", + Source = "GetVariableAccessAttributesLogicalNodeTree", + Confidence = 100 + }); + } + } + + return directory.AddSupplementalPoints(supplemental); + } + + private static string NormalizeFunctionalConstraint(string value) + { + var normalized = (value ?? string.Empty).Trim().ToUpperInvariant(); + return FunctionalConstraints.Contains(normalized) ? normalized : string.Empty; + } + + private static bool IsUsableComponentName(string value) + => !string.IsNullOrWhiteSpace(value) && + !string.Equals(value, "element", StringComparison.OrdinalIgnoreCase) && + value[0] != '[' && + !value.Contains('$') && + !value.Contains('.'); +} + internal sealed class LiveIedVariableTypeHierarchyIndex { + private const char CompositeKeySeparator = '\u001F'; + private readonly Dictionary _byMmsReference = new(StringComparer.OrdinalIgnoreCase); @@ -42,8 +178,36 @@ public static LiveIedVariableTypeHierarchyIndex Build( ArgumentNullException.ThrowIfNull(results); var index = new LiveIedVariableTypeHierarchyIndex(); + if (directory.PointCount == 0 || results.Count == 0) + return index; + + // The live LN-root GVA tree can contain semantic descendants that are not + // individually enumerated by GetNameList. Materialize those proven leaves + // before indexing so the canonical model and SCL builder can see them without + // issuing per-leaf MMS requests. + LiveIedTypeHierarchyPointMaterializer.Augment(directory, results); + + var pointsByLogicalNode = directory.Points + .GroupBy( + point => BuildLogicalNodeKey(point.Domain, point.LogicalNode), + StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => (IReadOnlyList)group.ToArray(), + StringComparer.OrdinalIgnoreCase); + foreach (var result in results.Where(result => result.IsSuccess && result.TypeSpecification is not null)) - index.AddResult(directory, result); + { + var rootParts = SplitMmsItem(result.Reference.Item); + if (rootParts.Length == 0 || string.IsNullOrWhiteSpace(result.Reference.Domain)) + continue; + + var logicalNodeKey = BuildLogicalNodeKey(result.Reference.Domain, rootParts[0]); + if (!pointsByLogicalNode.TryGetValue(logicalNodeKey, out var candidates)) + continue; + + index.AddResult(candidates, result, rootParts); + } return index; } @@ -51,24 +215,21 @@ public static LiveIedVariableTypeHierarchyIndex Build( public bool TryResolve(MmsFcResolvedPoint point, out LiveIedVariableTypeResolution resolution) => _byMmsReference.TryGetValue(point.MmsReference, out resolution!); - private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttributesResult result) + private void AddResult( + IReadOnlyList candidatePoints, + MmsVariableAccessAttributesResult result, + IReadOnlyList rootParts) { - var rootItem = result.Reference.Item.Trim(); - if (string.IsNullOrWhiteSpace(result.Reference.Domain) || string.IsNullOrWhiteSpace(rootItem) || result.TypeSpecification is null) + if (result.TypeSpecification is null) return; - var rootParts = SplitMmsItem(rootItem); - if (rootParts.Length == 0) - return; - - foreach (var point in directory.Points.Where(point => - string.Equals(point.Domain, result.Reference.Domain, StringComparison.OrdinalIgnoreCase))) + foreach (var point in candidatePoints) { var pointParts = SplitMmsItem(point.MmsItemName); if (!HasPrefix(pointParts, rootParts)) continue; - var remainder = pointParts[rootParts.Length..]; + var remainder = pointParts[rootParts.Count..]; var type = ResolvePath(result.TypeSpecification, remainder); if (type is null) continue; @@ -80,7 +241,7 @@ private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttribut type, source, $"Mapped from {result.ReferenceKey} type hierarchy. {result.Message}", - rootParts.Length); + rootParts.Count); if (!_byMmsReference.TryGetValue(point.MmsReference, out var existing) || resolution.Specificity > existing.Specificity) @@ -106,8 +267,13 @@ private void AddResult(MmsIedModelDirectory directory, MmsVariableAccessAttribut return current; } + private static string BuildLogicalNodeKey(string domain, string logicalNode) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, logicalNode ?? string.Empty); + private static string[] SplitMmsItem(string value) - => value.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private static bool HasPrefix(IReadOnlyList value, IReadOnlyList prefix) { diff --git a/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs b/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs new file mode 100644 index 00000000..5dcaf9b8 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.AssociationEvidence.cs @@ -0,0 +1,62 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Projects the exact accepted built-in association profile into canonical SCL + /// communication evidence. This is session evidence, not an exporter default. + /// Profiles that do not carry a qualified called AP-title remain explicitly + /// unresolved so a safe-connection export can fail closed. + /// + public LiveIedCommunicationEvidence GetAcceptedCommunicationEvidence(string accessPointName = "AP1") + { + var accepted = LastAssociationAttempts.LastOrDefault(attempt => attempt.IsAccepted); + var profileName = accepted?.ProfileName?.Trim() ?? string.Empty; + var association = ResolveAcceptedRemoteAssociation(profileName); + + return new LiveIedCommunicationEvidence + { + Source = string.IsNullOrWhiteSpace(profileName) + ? "AcceptedAssociationUnavailable" + : "AcceptedAssociationProfile", + AssociationProfileName = profileName, + Host = _lastHost?.Trim() ?? string.Empty, + Port = _lastPort <= 0 ? 102 : _lastPort, + AccessPointName = string.IsNullOrWhiteSpace(accessPointName) ? "AP1" : accessPointName.Trim(), + Association = association + }; + } + + private static SclIsoAssociationAddress ResolveAcceptedRemoteAssociation(string profileName) + { + // These values describe the called/remote side encoded by the shipped runtime + // association payloads. They are deliberately tied to the accepted profile name, + // not to any IED identity or vendor. If a future profile changes its wire identity, + // its evidence mapping must change with it and the round-trip regression will fail. + if (string.Equals(profileName, "BalancedApTitle", StringComparison.Ordinal)) + { + return new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + }; + } + + // LegacyMinimal intentionally does not provide a qualified called AP-title. + // Exporting a guessed AP-title would create an SCL that looks valid but is not + // evidence-backed, so leave it unresolved and let canonical export fail closed. + return new SclIsoAssociationAddress + { + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + }; + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs new file mode 100644 index 00000000..2b613704 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDataSetDirectories.cs @@ -0,0 +1,69 @@ +namespace AR.Iec61850.Mms; + +public sealed partial class MmsClientSession +{ + /// + /// Reads independent DataSet directories with the same association-aware bounded + /// request window used by smart structural discovery. Each individual request is + /// still executed through the existing observed directory reader, so P0-4 KPI + /// accounting remains authoritative and no extra MMS request is introduced. + /// Results are published in the original DataSet order, independent of worker + /// completion order. + /// + private async Task> GetObservedSmartDataSetDirectoriesPipelinedAsync( + IReadOnlyList dataSetReferences, + MmsIedModelDirectory directory, + int maxConcurrency, + CancellationToken cancellationToken) + { + var workerCount = MmsSmartDataSetPipelinePolicy.ResolveWorkerCount( + dataSetReferences.Count, + maxConcurrency); + if (workerCount == 0) + return Array.Empty(); + + var results = new MmsDataSetDirectoryResult?[dataSetReferences.Count]; + var nextIndex = -1; + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + // Keep publication deterministic: request completion may be concurrent, but + // consumers see the exact canonical DataSet reference order supplied by the + // discovery inventory. + return results + .Where(result => result is not null) + .Select(result => result!) + .ToArray(); + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= dataSetReferences.Count) + return; + + var singleton = new[] { dataSetReferences[index] }; + var observed = await GetObservedSmartDataSetDirectoriesAsync( + singleton, + directory, + cancellationToken) + .ConfigureAwait(false); + + if (observed.Count > 0) + results[index] = observed[0]; + + if (!IsMmsInitiated) + return; + } + } + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs new file mode 100644 index 00000000..c6fbfb14 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscovery.cs @@ -0,0 +1,668 @@ +using AR.Iec61850.Diagnostics; + +namespace AR.Iec61850.Mms; + +/// +/// Capture-informed discovery policy for building a usable IEC 61850 model with +/// bounded work. Live MMS remains authoritative; optional domain hints only affect +/// scheduling order and never filter or manufacture online evidence. +/// +public sealed class MmsSmartDiscoveryOptions +{ + /// + /// Requested upper bound for independent discovery operations. The effective + /// value is additionally capped by the peer's negotiated maxOutstandingCalling + /// when that evidence is available. + /// + public int MaxConcurrentChains { get; init; } = 8; + + /// + /// Conservative cap used when maxOutstandingCalling could not be decoded. + /// + public int UnknownPeerMaxConcurrentChains { get; init; } = 4; + + public int MaxDomains { get; init; } = 256; + public int MaxVariableNamesPerDomain { get; init; } = 20000; + public int MaxVariableListNamesPerDomain { get; init; } = 4096; + + /// + /// Hard guard for one GetNameList continuation chain. It prevents a broken IED + /// from keeping discovery alive indefinitely while still preserving partial data. + /// + public int MaxNameListPages { get; init; } = 64; + + /// + /// Exact MMS domain identifiers that should be scheduled first after live domain + /// enumeration. This is suitable for SCL expected-domain hints. It never changes + /// the selected live domain set and extra live domains are still discovered. + /// + public IReadOnlyList PriorityDomains { get; init; } = Array.Empty(); + + /// + /// Report attribute reads are enrichment, not structural discovery. Keep them off + /// by default so a usable LD/LN/DO/DA tree is not blocked by many value reads. + /// + public bool ProbeReportAttributes { get; init; } + public int MaxReportAttributeProbes { get; init; } = 32; + + public bool ReadDataSetDirectories { get; init; } + public int MaxDataSetDirectoryReads { get; init; } = 64; +} + +public sealed partial class MmsClientSession +{ + /// + /// Builds a structural MMS model with one VMD-domain enumeration and a fixed-size + /// worker pool for independent per-domain chains. Continuation pages inside one + /// chain are always sequential. Expected association loss is converted into a + /// partial result instead of allowing queued workers to crash Task.WhenAll. + /// + public async Task DiscoverSmartAsync( + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + options ??= new MmsSmartDiscoveryOptions(); + BeginSmartDiscoveryKpiGeneration(); + + var maxDomains = Math.Clamp(options.MaxDomains, 1, 4096); + var maxVariables = Math.Clamp(options.MaxVariableNamesPerDomain, 1, 100000); + var maxVariableLists = Math.Clamp(options.MaxVariableListNamesPerDomain, 1, 20000); + var maxPages = Math.Clamp(options.MaxNameListPages, 1, 256); + var effectiveWindow = ResolveSmartDiscoveryWindow(options); + + var domainsResult = await GetNameListPagedSmartAsync( + MmsGetNameListObjectClass.Domain, + null, + maxDomains, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + // Preserve valid domain evidence even when a later continuation page fails. + // The status remains partial/failed in diagnostics, but a recoverable fault + // must not erase already-observed IED structure. + var publishedDomains = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + domainsResult.Names, + maxDomains); + var scheduledDomains = MmsSmartDiscoveryPolicy.OrderDomainsForScheduling( + publishedDomains, + options.PriorityDomains); + + var workItems = new SmartNameChainWorkItem[scheduledDomains.Length * 2]; + var workIndex = 0; + foreach (var domain in scheduledDomains) + { + workItems[workIndex++] = new SmartNameChainWorkItem( + domain, + MmsGetNameListObjectClass.NamedVariable, + maxVariables); + workItems[workIndex++] = new SmartNameChainWorkItem( + domain, + MmsGetNameListObjectClass.NamedVariableList, + maxVariableLists); + } + + var chains = await RunSmartNameChainsAsync( + workItems, + effectiveWindow, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + var variablesByKey = chains + .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariable) + .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); + var listsByKey = chains + .Where(chain => chain.ObjectClass == MmsGetNameListObjectClass.NamedVariableList) + .ToDictionary(chain => chain.Domain, StringComparer.OrdinalIgnoreCase); + + // Publication order is derived from live evidence, not SCL scheduling hints. + var domainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var domainVariableLists = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var domain in publishedDomains) + { + domainVariables[domain] = variablesByKey.TryGetValue(domain, out var variables) + ? variables.Names + : Array.Empty(); + domainVariableLists[domain] = listsByKey.TryGetValue(domain, out var lists) + ? lists.Names + : Array.Empty(); + } + + var snapshot = new MmsDiscoverySnapshot + { + DomainVariables = domainVariables, + DomainVariableLists = domainVariableLists + }; + + var inventory = MmsReportDiscoveryMapper.BuildInventory(snapshot); + var iedDirectory = MmsIedModelDirectoryBuilder.Build(snapshot); + + var reportEnrichmentInterrupted = false; + if (options.ProbeReportAttributes && IsMmsInitiated) + { + try + { + await EnrichReportInventorySmartAsync( + inventory, + Math.Max(0, options.MaxReportAttributeProbes), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + reportEnrichmentInterrupted = true; + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + reportEnrichmentInterrupted = true; + } + } + + var dataSetReferences = options.ReadDataSetDirectories && IsMmsInitiated + ? inventory.DataSets + .Select(dataSet => dataSet.Reference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(Math.Clamp(options.MaxDataSetDirectoryReads, 0, 4096)) + .ToArray() + : Array.Empty(); + + IReadOnlyList dataSetDirectories; + if (dataSetReferences.Length == 0) + { + dataSetDirectories = Array.Empty(); + } + else + { + try + { + dataSetDirectories = (await GetObservedSmartDataSetDirectoriesPipelinedAsync( + dataSetReferences, + iedDirectory, + effectiveWindow, + cancellationToken) + .ConfigureAwait(false)) + .ToArray(); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + dataSetDirectories = Array.Empty(); + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + dataSetDirectories = Array.Empty(); + } + } + + var incompleteChains = chains.Count(chain => !chain.IsComplete); + var successfulDataSetDirectories = dataSetDirectories.Count(directory => directory.IsSuccess); + var discoveredDataSetMembers = dataSetDirectories + .Where(directory => directory.IsSuccess) + .Sum(directory => directory.Members.Count); + var negotiated = LastNegotiatedCapabilities.MaxOutstandingCalling; + var negotiatedText = negotiated.HasValue ? negotiated.Value.ToString() : "unknown"; + var domainStatus = !domainsResult.IsSuccess + ? $"domains={publishedDomains.Length}, domain-list=failed-after-partial:{domainsResult.Message}" + : domainsResult.MoreFollows + ? $"domains={publishedDomains.Length}, domain-list=partial" + : $"domains={publishedDomains.Length}, domain-list=complete"; + var dataSetDirectorySummary = options.ReadDataSetDirectories + ? $"dataset directories={successfulDataSetDirectories}/{dataSetReferences.Length}, dataset members={discoveredDataSetMembers}" + : "dataset directories=deferred"; + var reportSummary = !options.ProbeReportAttributes + ? "report enrichment=deferred" + : reportEnrichmentInterrupted + ? "report enrichment=partial" + : "report enrichment=completed"; + + UpdateSmartDiscoveryCompleteness(snapshot, iedDirectory, inventory, dataSetDirectories); + var kpi = LastSmartDiscoveryKpi; + var kpiSummary = kpi is null + ? "kpi=unavailable" + : $"kpi requests={kpi.TotalRequests}, duplicates={kpi.DuplicateRequests}, peak={kpi.PeakOutstandingRequests}, wireAccounting={(kpi.WireAccountingComplete ? "complete" : "partial")}, signature={kpi.DeterministicSignature}"; + + LastDiscoveryAttemptSummary = + $"Smart discovery: {domainStatus}, chains={chains.Length}, incompleteChains={incompleteChains}, " + + $"window={effectiveWindow}, negotiatedCalling={negotiatedText}, {reportSummary}, {kpiSummary}."; + + return new MmsDiscoveryResult + { + Snapshot = snapshot, + ReportInventory = inventory, + IedDirectory = iedDirectory, + DataSetDirectories = dataSetDirectories, + Summary = + $"Smart MMS discovery: LD={snapshot.DomainCount}, raw variables={snapshot.RawVariableCount}, " + + $"FC-points={iedDirectory.PointCount}, datasets={inventory.DataSets.Count}, {dataSetDirectorySummary}, " + + $"RCB={inventory.ReportControls.Count} (BRCB={inventory.BufferedCount}, URCB={inventory.UnbufferedCount}). " + + LastDiscoveryAttemptSummary + }; + } + + private async Task EnrichReportInventorySmartAsync( + MmsReportInventory inventory, + int maxReportAttributeProbes, + CancellationToken cancellationToken) + { + if (maxReportAttributeProbes <= 0 || + inventory.ReportControls.Count == 0 || + !IsMmsInitiated) + { + return; + } + + foreach (var reportControl in inventory.ReportControls + .OrderByDescending(control => control.Buffered) + .ThenByDescending(control => + control.LogicalNode.Equals("LLN0", StringComparison.OrdinalIgnoreCase)) + .Take(maxReportAttributeProbes)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // A complete RCB value is normally one MMS structure. Reading that structure + // first avoids 8-9 individual confirmed Reads per runtime instance while + // preserving the same static configuration evidence. + await TryReadReportControlStructureSmartAsync( + reportControl, + cancellationToken) + .ConfigureAwait(false); + + // Fall back only for static fields that the structured value did not expose. + // Every fallback is individually observed by the smart KPI recorder. + if (string.IsNullOrWhiteSpace(reportControl.DataSetReference)) + await TryReadReportAttributeSmartAsync(reportControl, "DatSet", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ReportId)) + await TryReadReportAttributeSmartAsync(reportControl, "RptID", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.ConfRev)) + await TryReadReportAttributeSmartAsync(reportControl, "ConfRev", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.IntegrityPeriodMs)) + await TryReadReportAttributeSmartAsync(reportControl, "IntgPd", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.BufferTimeMs)) + await TryReadReportAttributeSmartAsync(reportControl, "BufTm", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.TriggerOptions)) + await TryReadReportAttributeSmartAsync(reportControl, "TrgOps", cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(reportControl.OptionalFields)) + await TryReadReportAttributeSmartAsync(reportControl, "OptFlds", cancellationToken).ConfigureAwait(false); + + if (reportControl.Buffered) + { + if (string.IsNullOrWhiteSpace(reportControl.ReservationTimeSeconds)) + await TryReadReportAttributeSmartAsync(reportControl, "ResvTms", cancellationToken).ConfigureAwait(false); + } + else if (string.IsNullOrWhiteSpace(reportControl.ReservationState)) + { + await TryReadReportAttributeSmartAsync(reportControl, "Resv", cancellationToken).ConfigureAwait(false); + } + + reportControl.Status = HasUsefulReportProbeData(reportControl) + ? "Attribute-probed" + : reportControl.Status; + } + } + + private async Task TryReadReportControlStructureSmartAsync( + MmsReportControlCandidate reportControl, + CancellationToken cancellationToken) + { + try + { + var reference = MmsObjectReference.Parse( + reportControl.Reference, + reportControl.FunctionalConstraint); + using var observation = ObserveSmartDiscoveryRequest( + "report-enrichment", + "Read", + $"RCB:{reference.Domain}/{reference.Item}"); + var result = await ReadSingleVariableAsync(reference, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + + reportControl.ProbeDiagnostics.Add( + $"RCB base {reference.Item}: {(result.IsSuccess ? "OK" : result.Message)}"); + if (result.IsSuccess && result.Value is not null) + ApplyReportControlStructure(reportControl, result.Value); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + reportControl.ProbeDiagnostics.Add( + $"RCB base structure read failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private async Task TryReadReportAttributeSmartAsync( + MmsReportControlCandidate reportControl, + string attribute, + CancellationToken cancellationToken) + { + if (!string.Equals(attribute, "DatSet", StringComparison.OrdinalIgnoreCase) && + !reportControl.Attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase)) + { + return; + } + + try + { + var reference = MmsObjectReference.Parse( + $"{reportControl.Reference}.{attribute}", + reportControl.FunctionalConstraint); + using var observation = ObserveSmartDiscoveryRequest( + "report-enrichment", + "Read", + $"RCB:{reference.Domain}/{reference.Item}"); + var result = await ReadSingleVariableAsync(reference, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + + if (result.IsSuccess && result.Value is not null) + { + var text = NormalizeReportAttributeText(result.Value); + if (!string.IsNullOrWhiteSpace(text)) + ApplyReportAttributeText(reportControl, attribute, text); + reportControl.ProbeDiagnostics.Add( + $"{attribute} item={reference.Item}: OK {MmsDataValueRenderer.ToCompactString(result.Value)}"); + } + else + { + reportControl.ProbeDiagnostics.Add( + $"{attribute} item={reference.Item}: {result.Message}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + reportControl.ProbeDiagnostics.Add( + $"{attribute}: exception {ex.GetType().Name}: {ex.Message}"); + } + } + + internal int ResolveSmartDiscoveryWindow(MmsSmartDiscoveryOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return MmsSmartDiscoveryPolicy.ResolveWindow( + options.MaxConcurrentChains, + options.UnknownPeerMaxConcurrentChains, + LastNegotiatedCapabilities.MaxOutstandingCalling); + } + + private async Task RunSmartNameChainsAsync( + IReadOnlyList workItems, + int maxConcurrency, + int maxPages, + CancellationToken cancellationToken) + { + if (workItems.Count == 0) + return Array.Empty(); + + var results = new SmartNameChainResult?[workItems.Count]; + var nextIndex = -1; + var workerCount = Math.Min(Math.Max(1, maxConcurrency), workItems.Count); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + var materialized = new SmartNameChainResult[workItems.Count]; + for (var index = 0; index < workItems.Count; index++) + { + materialized[index] = results[index] ?? SmartNameChainResult.Skipped( + workItems[index], + "Skipped because the MMS association became unavailable before this chain started."); + } + + return materialized; + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= workItems.Count) + return; + + var item = workItems[index]; + results[index] = await ReadSmartNameChainSafeAsync( + item, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + return; + } + } + } + + private async Task ReadSmartNameChainSafeAsync( + SmartNameChainWorkItem item, + int maxPages, + CancellationToken cancellationToken) + { + if (!IsMmsInitiated) + return SmartNameChainResult.Skipped(item, "MMS association is unavailable."); + + try + { + var result = await GetNameListPagedSmartAsync( + item.ObjectClass, + item.Domain, + item.MaxNames, + maxPages, + cancellationToken) + .ConfigureAwait(false); + + var names = result.Names + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(item.MaxNames) + .ToArray(); + + return new SmartNameChainResult( + item.Domain, + item.ObjectClass, + result.IsSuccess && !result.MoreFollows, + names, + result.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return SmartNameChainResult.Skipped( + item, + "The receive pump stopped while this discovery chain was pending."); + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + return SmartNameChainResult.Skipped( + item, + $"Discovery chain stopped safely after association fault: {ex.GetType().Name}: {ex.Message}"); + } + } + + /// + /// Smart-path pager with O(n) boundary de-duplication plus explicit page, name, + /// and continuation-token guards. It intentionally does not use the legacy + /// List.Contains de-duplication loop on large directories. + /// + private async Task GetNameListPagedSmartAsync( + MmsGetNameListObjectClass objectClass, + string? domainId, + int maxNames, + int maxPages, + CancellationToken cancellationToken) + { + EnsureMmsReady(); + + var boundedNames = Math.Clamp(maxNames, 1, 100000); + var boundedPages = Math.Clamp(maxPages, 1, 256); + var names = new List(Math.Min(boundedNames, 1024)); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenContinuationTokens = new HashSet(StringComparer.OrdinalIgnoreCase); + var continueAfter = string.Empty; + var page = 0; + var incomplete = false; + var stopReason = string.Empty; + MmsNameListResult? last = null; + + while (page < boundedPages) + { + cancellationToken.ThrowIfCancellationRequested(); + page++; + + var requestContinueAfter = string.IsNullOrWhiteSpace(continueAfter) ? "" : continueAfter; + var invokeId = NextInvokeId(); + var request = MmsGetNameListRequest.Build( + invokeId, + objectClass, + domainId, + string.IsNullOrWhiteSpace(continueAfter) ? null : continueAfter); + LastDiscoveryRequestHex = HexDump.ToCompactString(request); + + using var observation = ObserveSmartDiscoveryRequest( + "structure", + "GetNameList", + $"{objectClass}|{domainId ?? "VMD"}|continueAfter={requestContinueAfter}"); + try + { + var response = await SendConfirmedPresentationPayloadAsync( + request, + invokeId, + cancellationToken) + .ConfigureAwait(false); + last = MmsGetNameListResponseDecoder.Decode(response, invokeId); + LastDiscoveryResponseHex = last.ResponseHexPreview; + observation.Complete(last.IsSuccess); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (IsExpectedSmartDiscoveryFault(ex)) + { + await MarkProtocolFaultAsync().ConfigureAwait(false); + return new MmsNameListResult + { + IsSuccess = false, + Names = names, + MoreFollows = false, + Message = $"GetNameList {objectClass}/{domainId ?? "VMD"} transport fault on page {page}: {ex.GetType().Name}: {ex.Message}", + ResponseHexPreview = LastDiscoveryResponseHex + }; + } + + if (!last.IsSuccess) + { + return new MmsNameListResult + { + IsSuccess = false, + Names = names, + MoreFollows = false, + Message = last.Message, + ResponseHexPreview = last.ResponseHexPreview + }; + } + + var before = names.Count; + var truncatedThisPage = false; + foreach (var rawName in last.Names) + { + if (string.IsNullOrWhiteSpace(rawName)) + continue; + + var name = rawName.Trim(); + if (!seenNames.Add(name)) + continue; + + if (names.Count < boundedNames) + names.Add(name); + else + truncatedThisPage = true; + } + + var newCount = names.Count - before; + if (truncatedThisPage) + { + incomplete = true; + stopReason = $"name limit {boundedNames} reached inside page {page}"; + break; + } + + if (!last.MoreFollows) + break; + + if (names.Count >= boundedNames) + { + incomplete = true; + stopReason = $"name limit {boundedNames} reached"; + break; + } + + if (newCount <= 0) + { + incomplete = true; + stopReason = "IED returned no new names while moreFollows remained true"; + break; + } + + var nextContinueAfter = last.Names.LastOrDefault()?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(nextContinueAfter)) + { + incomplete = true; + stopReason = "IED returned moreFollows without a continuation token"; + break; + } + + if (!seenContinuationTokens.Add(nextContinueAfter)) + { + incomplete = true; + stopReason = $"continuation token repeated ({nextContinueAfter})"; + break; + } + + continueAfter = nextContinueAfter; + } + + if (page >= boundedPages && last?.MoreFollows == true) + { + incomplete = true; + stopReason = $"page limit {boundedPages} reached"; + } + + var moreFollows = incomplete || (last?.MoreFollows ?? false); + var suffix = string.IsNullOrWhiteSpace(stopReason) ? string.Empty : $" Stopped safely: {stopReason}."; + return new MmsNameListResult + { + IsSuccess = true, + Names = names, + MoreFollows = moreFollows, + MoreFollowsWasPresent = last?.MoreFollowsWasPresent ?? false, + Message = $"Smart GetNameList {objectClass}/{domainId ?? "VMD"}: names={names.Count}, pages={page}, complete={!moreFollows}.{suffix}", + ResponseHexPreview = last?.ResponseHexPreview ?? string.Empty + }; + } + + private static bool IsExpectedSmartDiscoveryFault(Exception ex) + => ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException; + + private sealed record SmartNameChainWorkItem( + string Domain, + MmsGetNameListObjectClass ObjectClass, + int MaxNames); + + private sealed record SmartNameChainResult( + string Domain, + MmsGetNameListObjectClass ObjectClass, + bool IsComplete, + IReadOnlyList Names, + string Message) + { + public static SmartNameChainResult Skipped(SmartNameChainWorkItem item, string message) + => new(item.Domain, item.ObjectClass, false, Array.Empty(), message); + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs new file mode 100644 index 00000000..59e36114 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartDiscoverySingleFlight.cs @@ -0,0 +1,193 @@ +namespace AR.Iec61850.Mms; + +/// +/// Association-scoped single-flight wrapper for smart discovery. Multiple consumers +/// asking for the same bounded discovery profile on one live MMS association share the +/// same wire operation. Cancelling one waiter never cancels the shared discovery. +/// +public sealed partial class MmsClientSession +{ + private readonly object _smartDiscoverySingleFlightSync = new(); + private object? _smartDiscoverySingleFlightAssociationMarker; + private string _smartDiscoverySingleFlightHost = string.Empty; + private int _smartDiscoverySingleFlightPort; + private SmartDiscoverySingleFlightKey? _smartDiscoverySingleFlightKey; + private Task? _smartDiscoverySingleFlightTask; + + /// + /// Runs at most once for an identical smart + /// discovery profile on the current accepted association. The underlying operation + /// is intentionally independent from an individual caller's cancellation token; + /// waiter cancellation only stops that waiter. Association loss still terminates + /// the shared operation through the normal transport/receive-pump fault path. + /// + public async Task DiscoverSmartSingleFlightAsync( + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + options ??= new MmsSmartDiscoveryOptions(); + + var associationMarker = LastAssociationAttempts; + var host = _lastHost; + var port = _lastPort; + var key = SmartDiscoverySingleFlightKey.From(options); + + Task sharedTask; + lock (_smartDiscoverySingleFlightSync) + { + if (!IsSameSmartDiscoveryAssociation(associationMarker, host, port)) + ResetSmartDiscoverySingleFlightUnsafe(); + + if (_smartDiscoverySingleFlightTask != null && + _smartDiscoverySingleFlightKey.HasValue && + _smartDiscoverySingleFlightKey.Value.Equals(key)) + { + sharedTask = _smartDiscoverySingleFlightTask; + } + else + { + _smartDiscoverySingleFlightAssociationMarker = associationMarker; + _smartDiscoverySingleFlightHost = host; + _smartDiscoverySingleFlightPort = port; + _smartDiscoverySingleFlightKey = key; + + sharedTask = DiscoverSmartAsync(options, CancellationToken.None); + _smartDiscoverySingleFlightTask = sharedTask; + ObserveSmartDiscoverySingleFlightCompletion(sharedTask); + } + } + + return await sharedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Returns the domain-variable inventory already established by the authoritative + /// smart discovery on this association. Consumers such as Control must reuse this + /// evidence instead of issuing another VMD + per-domain GetNameList sweep. + /// + /// If no complete smart inventory exists for the current association, the legacy + /// browse remains the compatibility fallback. This keeps standalone control usage + /// safe while making a smart-discovered session wire-idempotent for later consumers. + /// + internal async Task>> GetAuthoritativeDomainVariableNamesAsync( + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + + Task? sharedTask = null; + lock (_smartDiscoverySingleFlightSync) + { + if (IsSameSmartDiscoveryAssociation(LastAssociationAttempts, _lastHost, _lastPort)) + sharedTask = _smartDiscoverySingleFlightTask; + } + + if (sharedTask != null) + { + try + { + var discovery = await sharedTask.WaitAsync(cancellationToken).ConfigureAwait(false); + if (IsReusableSmartDiscoveryResult(discovery)) + return discovery.Snapshot.DomainVariables; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The shared discovery owns its own lifetime. If it ended unexpectedly, + // fall through to the compatibility browse while the association lives. + } + catch (Exception) when (IsMmsInitiated) + { + // Do not let a faulted/partial shared generation poison later consumers. + // A live association may still support the conservative legacy browse. + } + } + + return await DiscoverDomainVariableNamesAsync(cancellationToken).ConfigureAwait(false); + } + + private void ObserveSmartDiscoverySingleFlightCompletion(Task task) + { + _ = task.ContinueWith( + completed => + { + var reusable = completed.Status == TaskStatus.RanToCompletion && + IsReusableSmartDiscoveryResult(completed.Result); + + if (reusable) + return; + + lock (_smartDiscoverySingleFlightSync) + { + if (ReferenceEquals(_smartDiscoverySingleFlightTask, completed)) + ResetSmartDiscoverySingleFlightUnsafe(); + } + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private bool IsSameSmartDiscoveryAssociation(object associationMarker, string host, int port) + => IsMmsInitiated && + ReferenceEquals(_smartDiscoverySingleFlightAssociationMarker, associationMarker) && + string.Equals(_smartDiscoverySingleFlightHost, host, StringComparison.OrdinalIgnoreCase) && + _smartDiscoverySingleFlightPort == port; + + private bool IsReusableSmartDiscoveryResult(MmsDiscoveryResult result) + { + if (!IsMmsInitiated || result.Snapshot.DomainCount <= 0) + return false; + + // DiscoverSmartAsync deliberately returns partial evidence for recoverable MMS + // failures. Partial evidence is useful to the initiating caller but must never + // poison the association cache; a later caller must be allowed to retry. + var summary = result.Summary ?? string.Empty; + return summary.Contains("domain-list=complete", StringComparison.OrdinalIgnoreCase) && + summary.Contains("incompleteChains=0", StringComparison.OrdinalIgnoreCase); + } + + private void ResetSmartDiscoverySingleFlightUnsafe() + { + _smartDiscoverySingleFlightAssociationMarker = null; + _smartDiscoverySingleFlightHost = string.Empty; + _smartDiscoverySingleFlightPort = 0; + _smartDiscoverySingleFlightKey = null; + _smartDiscoverySingleFlightTask = null; + } + + private readonly record struct SmartDiscoverySingleFlightKey( + int MaxConcurrentChains, + int UnknownPeerMaxConcurrentChains, + int MaxDomains, + int MaxVariableNamesPerDomain, + int MaxVariableListNamesPerDomain, + int MaxNameListPages, + bool ProbeReportAttributes, + int MaxReportAttributeProbes, + bool ReadDataSetDirectories, + int MaxDataSetDirectoryReads, + string PriorityDomains) + { + public static SmartDiscoverySingleFlightKey From(MmsSmartDiscoveryOptions options) + { + var priorityDomains = string.Join( + "\u001F", + (options.PriorityDomains ?? Array.Empty()) + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Select(domain => domain.Trim().ToUpperInvariant())); + + return new SmartDiscoverySingleFlightKey( + options.MaxConcurrentChains, + options.UnknownPeerMaxConcurrentChains, + options.MaxDomains, + options.MaxVariableNamesPerDomain, + options.MaxVariableListNamesPerDomain, + options.MaxNameListPages, + options.ProbeReportAttributes, + options.MaxReportAttributeProbes, + options.ReadDataSetDirectories, + options.MaxDataSetDirectoryReads, + priorityDomains); + } + } +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs new file mode 100644 index 00000000..ca8a6b90 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartInitialFcRead.cs @@ -0,0 +1,324 @@ +namespace AR.Iec61850.Mms; + +/// +/// Bounded execution policy for the existing FC-root initial Read plan. This changes +/// only scheduling; target selection stays in the canonical/SCL/live planners. +/// +public sealed class MmsSmartInitialFcReadOptions +{ + /// + /// Maximum number of independent batch Reads that may be outstanding together. + /// The effective value is additionally capped by negotiated maxOutstandingCalling. + /// + public int MaxOutstandingBatches { get; init; } = 4; + + /// + /// Conservative cap when the association limit could not be decoded. + /// + public int UnknownPeerMaxOutstandingBatches { get; init; } = 2; + + /// + /// Optional explicit deadline for one confirmed Read batch. Non-positive values + /// reuse the session connection timeout. + /// + public TimeSpan PerBatchTimeout { get; init; } +} + +public sealed partial class MmsClientSession +{ + /// + /// Executes the existing bounded FC-root Read plan with a fixed-size worker pool. + /// Each batch remains one ordered MMS Confirmed-Read, responses remain invoke-ID + /// correlated, and final batch publication is deterministic by plan index. + /// + /// A timeout or transport fault closes the association exactly once because a + /// confirmed request may already be on the wire. Other workers then fail soft and + /// the valid completed batches are preserved in the result. + /// + public async Task ExecuteInitialFcReadPlanSmartAsync( + InitialFcReadPlan plan, + MmsSmartInitialFcReadOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + options ??= new MmsSmartInitialFcReadOptions(); + + var effectiveTimeout = options.PerBatchTimeout > TimeSpan.Zero + ? options.PerBatchTimeout + : _lastTimeout > TimeSpan.Zero + ? _lastTimeout + : TimeSpan.FromSeconds(5); + + var validationError = ValidateSmartInitialFcReadPlan(plan); + if (!string.IsNullOrWhiteSpace(validationError)) + { + return new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.InvalidPlan, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Message = validationError + }; + } + + if (!IsMmsInitiated || !IsTransportConnected) + { + return new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.SessionNotReady, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Message = $"Smart initial FC-root Read requires an initiated MMS association; current state={State}." + }; + } + + var orderedBatches = plan.Batches + .OrderBy(batch => batch.Index) + .ToArray(); + var executions = new InitialFcReadBatchExecution?[orderedBatches.Length]; + var outcomes = new SmartInitialReadBatchOutcome[orderedBatches.Length]; + var nextIndex = -1; + var resetStarted = 0; + var requestedWindow = Math.Clamp(options.MaxOutstandingBatches, 1, 16); + var unknownWindow = Math.Clamp(options.UnknownPeerMaxOutstandingBatches, 1, 8); + var window = MmsSmartDiscoveryPolicy.ResolveWindow( + requestedWindow, + unknownWindow, + LastNegotiatedCapabilities.MaxOutstandingCalling); + var workerCount = Math.Min(window, orderedBatches.Length); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + try + { + await Task.WhenAll(workers).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await ResetAssociationOnceAsync().ConfigureAwait(false); + throw; + } + + var materialized = new List(orderedBatches.Length); + for (var index = 0; index < orderedBatches.Length; index++) + { + if (executions[index] is not null) + { + materialized.Add(executions[index]!); + continue; + } + + var batch = orderedBatches[index]; + var message = "Batch was not started because the MMS association became unavailable."; + materialized.Add(new InitialFcReadBatchExecution + { + BatchIndex = batch.Index, + Targets = batch.Targets, + Read = BuildFailedInitialFcBatch(batch.References, message) + }); + outcomes[index] = SmartInitialReadBatchOutcome.Skipped; + } + + var failedTargets = materialized.Sum(batch => batch.Read.Results.Count(result => !result.IsSuccess)); + var projectionErrors = materialized.Sum(batch => batch.Projections.Sum(projection => projection.Errors.Count)); + var timedOut = outcomes.Any(outcome => outcome == SmartInitialReadBatchOutcome.TimedOut); + var transportFailure = outcomes.Any(outcome => outcome == SmartInitialReadBatchOutcome.TransportFailure); + var skipped = outcomes.Count(outcome => outcome == SmartInitialReadBatchOutcome.Skipped); + var status = timedOut + ? InitialFcReadExecutionStatus.TimedOut + : transportFailure + ? InitialFcReadExecutionStatus.TransportFailure + : failedTargets == 0 && projectionErrors == 0 && skipped == 0 + ? InitialFcReadExecutionStatus.Completed + : InitialFcReadExecutionStatus.Partial; + + return new InitialFcReadExecutionResult + { + Status = status, + Plan = plan, + PerBatchTimeout = effectiveTimeout, + Batches = materialized, + Message = + $"Smart initial FC-root Read: batches={materialized.Count}/{orderedBatches.Length}, " + + $"window={window}, negotiatedCalling={LastNegotiatedCapabilities.MaxOutstandingCalling?.ToString() ?? "unknown"}, " + + $"successfulTargets={materialized.Sum(batch => batch.Read.Results.Count(result => result.IsSuccess))}, " + + $"failedTargets={failedTargets}, projectedLeaves={materialized.Sum(batch => batch.Projections.Sum(projection => projection.Leaves.Count))}, " + + $"projectionErrors={projectionErrors}, skippedBatches={skipped}." + }; + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated || Volatile.Read(ref resetStarted) != 0) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= orderedBatches.Length) + return; + + var batch = orderedBatches[index]; + var execution = await ExecuteSmartInitialReadBatchAsync( + batch, + effectiveTimeout, + cancellationToken) + .ConfigureAwait(false); + executions[index] = execution.Execution; + outcomes[index] = execution.Outcome; + + if (execution.RequiresAssociationReset) + { + await ResetAssociationOnceAsync().ConfigureAwait(false); + return; + } + } + } + + async Task ResetAssociationOnceAsync() + { + if (Interlocked.CompareExchange(ref resetStarted, 1, 0) != 0) + return; + + await MarkProtocolFaultAsync().ConfigureAwait(false); + } + } + + private async Task ExecuteSmartInitialReadBatchAsync( + InitialFcReadBatch batch, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var references = batch.References; + var invokeId = NextInvokeId(); + var request = MmsReadBatchCodec.BuildRequest( + invokeId, + references, + MmsReadPayloadProfile.PresentationDataValues); + + using var observation = ObserveSmartDiscoveryRequest( + "initial-read", + "Read", + BuildSmartInitialReadLogicalKey(references)); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + + MmsReadBatchResult read; + try + { + var response = await SendConfirmedPresentationPayloadAsync( + request, + invokeId, + deadline.Token) + .ConfigureAwait(false); + read = MmsReadBatchCodec.DecodeResponse(response, references, invokeId); + observation.Complete(read.IsSuccess); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && deadline.IsCancellationRequested) + { + var message = $"Smart initial FC-root Read batch {batch.Index} timed out after {timeout.TotalMilliseconds:0} ms."; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.TimedOut, + RequiresAssociationReset: true); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + var message = $"Smart initial FC-root Read batch {batch.Index} stopped because the receive pump/association closed."; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.Skipped, + RequiresAssociationReset: false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + var message = $"Smart initial FC-root Read batch {batch.Index} transport/session failure: {ex.GetType().Name}: {ex.Message}"; + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, BuildFailedInitialFcBatch(references, message)), + SmartInitialReadBatchOutcome.TransportFailure, + RequiresAssociationReset: true); + } + + return new SmartInitialReadBatchResult( + BuildSmartInitialReadExecution(batch, read), + read.IsSuccess + ? SmartInitialReadBatchOutcome.Completed + : SmartInitialReadBatchOutcome.Partial, + RequiresAssociationReset: false); + } + + private static string BuildSmartInitialReadLogicalKey(IReadOnlyList references) + => string.Join( + ";", + references.Select(reference => + $"{reference.Domain}/{reference.Item}/{reference.FunctionalConstraint}")); + + private static InitialFcReadBatchExecution BuildSmartInitialReadExecution( + InitialFcReadBatch batch, + MmsReadBatchResult read) + { + var projections = new List(); + for (var index = 0; index < batch.Targets.Count && index < read.Results.Count; index++) + { + var access = read.Results[index]; + var target = batch.Targets[index]; + if (!access.IsSuccess || access.Value is null || target.DataObjects.Count == 0) + continue; + + projections.Add(InitialFcValueProjector.Project(target, access.Value)); + } + + return new InitialFcReadBatchExecution + { + BatchIndex = batch.Index, + Targets = batch.Targets, + Read = read, + Projections = projections + }; + } + + private static string ValidateSmartInitialFcReadPlan(InitialFcReadPlan plan) + { + if (!plan.IsValid) + return "Initial FC-root Read plan is invalid: " + string.Join(" | ", plan.Errors); + if (plan.MaximumVariableReferencesPerRead < 1 || + plan.MaximumVariableReferencesPerRead > MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + return $"Initial FC-root Read plan exceeds the bounded per-request limit of {MmsReadBatchCodec.MaximumVariableReferencesPerRead}."; + } + + foreach (var batch in plan.Batches) + { + if (batch.Targets.Count == 0) + return $"Initial FC-root Read batch {batch.Index} is empty."; + if (batch.Targets.Count > plan.MaximumVariableReferencesPerRead || + batch.Targets.Count > MmsReadBatchCodec.MaximumVariableReferencesPerRead) + { + return $"Initial FC-root Read batch {batch.Index} contains {batch.Targets.Count} targets and exceeds the bounded request size."; + } + } + + return string.Empty; + } + + private enum SmartInitialReadBatchOutcome + { + None, + Completed, + Partial, + TimedOut, + TransportFailure, + Skipped + } + + private sealed record SmartInitialReadBatchResult( + InitialFcReadBatchExecution Execution, + SmartInitialReadBatchOutcome Outcome, + bool RequiresAssociationReset); +} diff --git a/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs new file mode 100644 index 00000000..316e872a --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsClientSession.SmartVariableAccessAttributes.cs @@ -0,0 +1,375 @@ +namespace AR.Iec61850.Mms; + +/// +/// Diagnostic request-budget evidence for one smart hierarchy GVA pass. Counts describe +/// the bounded probe ladder; they never influence canonical model semantics. +/// +public sealed class MmsSmartTypeProbeBudgetSnapshot +{ + public int DirectoryPoints { get; init; } + public int SuppliedLogicalNodeCandidates { get; init; } + public int SuppressedNonLiveLogicalNodeCandidates { get; init; } + public int LogicalNodeRequests { get; init; } + public int PointsCoveredByLogicalNode { get; init; } + public int DataObjectRequests { get; init; } + public int PointsCoveredByDataObject { get; init; } + public int ExactLeafRequests { get; init; } + public int SuppressedExactRepeatRequests { get; init; } + public int PointsCoveredByExactLeaf { get; init; } + public int RemainingUnresolvedPoints { get; init; } + public int TotalPlannedRequests => LogicalNodeRequests + DataObjectRequests + ExactLeafRequests; + + public string Summary => + $"Smart type budget: points={DirectoryPoints}, LN={LogicalNodeRequests}, " + + $"DO={DataObjectRequests}, leaf={ExactLeafRequests}, total={TotalPlannedRequests}, " + + $"covered(LN/DO/leaf)={PointsCoveredByLogicalNode}/{PointsCoveredByDataObject}/{PointsCoveredByExactLeaf}, " + + $"suppressed(nonLive/repeat)={SuppressedNonLiveLogicalNodeCandidates}/{SuppressedExactRepeatRequests}, " + + $"unresolved={RemainingUnresolvedPoints}."; +} + +public sealed partial class MmsClientSession +{ + private MmsSmartTypeProbeBudgetSnapshot? _lastSmartTypeProbeBudget; + + /// + /// Most recent hierarchy GVA budget. This is local diagnostic evidence only and + /// does not issue requests or alter the discovered IEC 61850 model. + /// + public MmsSmartTypeProbeBudgetSnapshot? LastSmartTypeProbeBudget + => Volatile.Read(ref _lastSmartTypeProbeBudget); + + /// + /// Convenience entry point for live-only callers. Canonical discovery code should + /// prefer the overload that supplies logical-node root candidates from its semantic + /// probe planner. The fallback ladder is LN root -> unresolved DO root -> unresolved + /// exact leaf, with exact-reference repeat suppression across tiers. + /// + public Task> GetVariableAccessAttributesSmartAsync( + MmsIedModelDirectory directory, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(directory); + var logicalNodeRoots = directory.LogicalDevices.Values + .OrderBy(device => device.Name, StringComparer.OrdinalIgnoreCase) + .SelectMany(device => device.LogicalNodes.Values + .OrderBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .Select(node => new MmsObjectReference(device.Name, node.Name, string.Empty))) + .ToArray(); + + return GetVariableAccessAttributesSmartAsync( + directory, + logicalNodeRoots, + options, + cancellationToken); + } + + /// + /// Executes a bounded, coverage-aware type discovery ladder. A successful parent + /// TypeSpecification suppresses every descendant probe it can prove. Only unresolved + /// branches descend to distinct DO roots and finally exact leaves. An exact GVA + /// reference is never reissued inside one pass merely because a shallower tier failed. + /// + public async Task> GetVariableAccessAttributesSmartAsync( + MmsIedModelDirectory directory, + IEnumerable logicalNodeRootCandidates, + MmsSmartDiscoveryOptions? options = null, + CancellationToken cancellationToken = default) + { + EnsureMmsReady(); + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(logicalNodeRootCandidates); + options ??= new MmsSmartDiscoveryOptions(); + + var points = directory.Points + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.LogicalNode) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (points.Length == 0) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot()); + return Array.Empty(); + } + + var suppliedCandidates = logicalNodeRootCandidates + .Where(reference => !string.IsNullOrWhiteSpace(reference.Domain) && + !string.IsNullOrWhiteSpace(reference.Item) && + !reference.Item.Contains('$', StringComparison.Ordinal)) + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .ToArray(); + var logicalNodeRoots = MmsSmartTypeProbePolicy.SelectLiveLogicalNodeRoots(directory, suppliedCandidates); + var suppressedNonLiveRoots = Math.Max(0, suppliedCandidates.Length - logicalNodeRoots.Length); + if (logicalNodeRoots.Length == 0) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + RemainingUnresolvedPoints = points.Length + }); + return Array.Empty(); + } + + var window = ResolveSmartDiscoveryWindow(options); + var results = new List(logicalNodeRoots.Length); + var probedReferences = new List(logicalNodeRoots); + + var logicalNodeResults = await RunVariableAttributeBatchAsync( + logicalNodeRoots, + window, + cancellationToken) + .ConfigureAwait(false); + results.AddRange(logicalNodeResults); + + var logicalNodeIndex = logicalNodeResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); + + var unresolvedAfterLogicalNode = new List(); + foreach (var point in points) + { + var logicalNodeRoot = new MmsObjectReference(point.Domain, point.LogicalNode, string.Empty); + if (logicalNodeIndex.TryGetValue(logicalNodeRoot, out var rootResult) && + MmsSmartTypeProbePolicy.Covers(rootResult, point.MmsItemName)) + { + continue; + } + + unresolvedAfterLogicalNode.Add(point); + } + + var coveredByLogicalNode = points.Length - unresolvedAfterLogicalNode.Count; + if (unresolvedAfterLogicalNode.Count == 0 || !IsMmsInitiated) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + RemainingUnresolvedPoints = unresolvedAfterLogicalNode.Count + }); + return results; + } + + var dataObjectRoots = unresolvedAfterLogicalNode + .Select(MmsSmartTypeProbePolicy.BuildDataObjectRoot) + .Where(reference => !string.IsNullOrWhiteSpace(reference.Item)) + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .OrderBy(reference => reference.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(reference => reference.Item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + probedReferences.AddRange(dataObjectRoots); + + var dataObjectResults = await RunVariableAttributeBatchAsync( + dataObjectRoots, + window, + cancellationToken) + .ConfigureAwait(false); + results.AddRange(dataObjectResults); + + var dataObjectIndex = dataObjectResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); + + var unresolvedAfterDataObject = new List(); + foreach (var point in unresolvedAfterLogicalNode) + { + var dataObjectRoot = MmsSmartTypeProbePolicy.BuildDataObjectRoot(point); + if (dataObjectIndex.TryGetValue(dataObjectRoot, out var rootResult) && + MmsSmartTypeProbePolicy.Covers(rootResult, point.MmsItemName)) + { + continue; + } + + unresolvedAfterDataObject.Add(point); + } + + var coveredByDataObject = unresolvedAfterLogicalNode.Count - unresolvedAfterDataObject.Count; + if (unresolvedAfterDataObject.Count == 0 || !IsMmsInitiated) + { + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + DataObjectRequests = dataObjectRoots.Length, + PointsCoveredByDataObject = coveredByDataObject, + RemainingUnresolvedPoints = unresolvedAfterDataObject.Count + }); + return results; + } + + var exactFallbackCandidates = unresolvedAfterDataObject + .Select(point => point.ToObjectReference()) + .Distinct(MmsObjectReferenceKeyComparer.Instance) + .ToArray(); + var exactLeafFallbacks = MmsSmartTypeProbePolicy.BuildUnprobedExactFallbacks( + unresolvedAfterDataObject, + probedReferences); + var suppressedExactRepeats = Math.Max(0, exactFallbackCandidates.Length - exactLeafFallbacks.Length); + + MmsVariableAccessAttributesResult[] exactLeafResults = Array.Empty(); + if (exactLeafFallbacks.Length > 0 && IsMmsInitiated) + { + exactLeafResults = await RunVariableAttributeBatchAsync( + exactLeafFallbacks, + window, + cancellationToken) + .ConfigureAwait(false); + results.AddRange(exactLeafResults); + } + + var exactLeafIndex = exactLeafResults + .GroupBy(result => result.Reference, MmsObjectReferenceKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.Last(), MmsObjectReferenceKeyComparer.Instance); + var remainingUnresolved = 0; + foreach (var point in unresolvedAfterDataObject) + { + var exactReference = point.ToObjectReference(); + if (exactLeafIndex.TryGetValue(exactReference, out var exactResult) && + MmsSmartTypeProbePolicy.Covers(exactResult, point.MmsItemName)) + { + continue; + } + + remainingUnresolved++; + } + + var coveredByExactLeaf = unresolvedAfterDataObject.Count - remainingUnresolved; + PublishSmartTypeProbeBudget(new MmsSmartTypeProbeBudgetSnapshot + { + DirectoryPoints = points.Length, + SuppliedLogicalNodeCandidates = suppliedCandidates.Length, + SuppressedNonLiveLogicalNodeCandidates = suppressedNonLiveRoots, + LogicalNodeRequests = logicalNodeRoots.Length, + PointsCoveredByLogicalNode = coveredByLogicalNode, + DataObjectRequests = dataObjectRoots.Length, + PointsCoveredByDataObject = coveredByDataObject, + ExactLeafRequests = exactLeafFallbacks.Length, + SuppressedExactRepeatRequests = suppressedExactRepeats, + PointsCoveredByExactLeaf = coveredByExactLeaf, + RemainingUnresolvedPoints = remainingUnresolved + }); + + return results; + } + + private void PublishSmartTypeProbeBudget(MmsSmartTypeProbeBudgetSnapshot snapshot) + => Volatile.Write(ref _lastSmartTypeProbeBudget, snapshot); + + private async Task RunVariableAttributeBatchAsync( + IReadOnlyList references, + int maxConcurrency, + CancellationToken cancellationToken) + { + if (references.Count == 0) + return Array.Empty(); + + var results = new MmsVariableAccessAttributesResult?[references.Count]; + var nextIndex = -1; + var workerCount = Math.Min(Math.Max(1, maxConcurrency), references.Count); + var workers = new Task[workerCount]; + + for (var worker = 0; worker < workerCount; worker++) + workers[worker] = WorkerAsync(); + + await Task.WhenAll(workers).ConfigureAwait(false); + + var materialized = new MmsVariableAccessAttributesResult[references.Count]; + for (var index = 0; index < references.Count; index++) + { + materialized[index] = results[index] ?? BuildUnavailableVariableTypeResult( + references[index], + "Skipped because the MMS association became unavailable before this probe started."); + } + + return materialized; + + async Task WorkerAsync() + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!IsMmsInitiated) + return; + + var index = Interlocked.Increment(ref nextIndex); + if (index >= references.Count) + return; + + var reference = references[index]; + results[index] = await ReadVariableAttributesSafeAsync(reference, cancellationToken) + .ConfigureAwait(false); + + if (!IsMmsInitiated) + return; + } + } + } + + private async Task ReadVariableAttributesSafeAsync( + MmsObjectReference reference, + CancellationToken cancellationToken) + { + if (!IsMmsInitiated) + return BuildUnavailableVariableTypeResult(reference, "MMS association is unavailable."); + + using var observation = ObserveSmartDiscoveryRequest( + "type-enrichment", + "GetVariableAccessAttributes", + $"{reference.Domain}/{reference.Item}"); + + try + { + var result = await GetVariableAccessAttributesAsync(reference, cancellationToken).ConfigureAwait(false); + observation.Complete(result.IsSuccess); + return result; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return BuildUnavailableVariableTypeResult( + reference, + "The receive pump stopped while this type probe was pending."); + } + catch (Exception ex) when (ex is IOException or InvalidDataException or ObjectDisposedException or InvalidOperationException) + { + return BuildUnavailableVariableTypeResult( + reference, + $"Type probe stopped safely after association fault: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static MmsVariableAccessAttributesResult BuildUnavailableVariableTypeResult( + MmsObjectReference reference, + string message) + => new() + { + IsSuccess = false, + Reference = reference, + Message = message, + Source = "SmartTypeProbe" + }; + + private sealed class MmsObjectReferenceKeyComparer : IEqualityComparer + { + public static MmsObjectReferenceKeyComparer Instance { get; } = new(); + + public bool Equals(MmsObjectReference x, MmsObjectReference y) + => string.Equals(x.Domain, y.Domain, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Item, y.Item, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode(MmsObjectReference obj) + => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Domain ?? string.Empty), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item ?? string.Empty)); + } +} diff --git a/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs b/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs index 1c4b78ed..12917065 100644 --- a/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsDataSetDirectory.cs @@ -141,9 +141,11 @@ public static MmsDataSetDirectoryResult Decode( var rawMembers = new List<(string Domain, string Item)>(); DecodeServiceResponse(service, rawMembers, ref deletable); + // DataSet membership is positional evidence. Preserve the exact BER traversal + // order and multiplicity returned by the IED: report values are indexed against + // this sequence, so member-level de-duplication can silently corrupt projection. var members = rawMembers .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.Item)) - .DistinctBy(x => $"{x.Domain}/{x.Item}", StringComparer.OrdinalIgnoreCase) .Select(x => NormalizeMember(x.Domain, x.Item, iedDirectory)) .ToArray(); diff --git a/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs b/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs index e8855add..9a6c11f5 100644 --- a/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs +++ b/src/AR.Iec61850/Mms/MmsIedModelDirectory.cs @@ -2,45 +2,17 @@ namespace AR.Iec61850.Mms; public sealed class MmsIedModelDirectory { - private readonly Dictionary _logicalDevices; - private readonly Dictionary> _pointsByUserReference; - private readonly Dictionary _pointsByMmsReference; + private readonly Dictionary _logicalDevices = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _pointsByUserReference = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pointsByMmsReference = new(StringComparer.OrdinalIgnoreCase); public MmsIedModelDirectory(IEnumerable points) { ArgumentNullException.ThrowIfNull(points); - - Points = points - .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.MmsItemName)) - .OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.LogicalNode, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.DataObjectPath, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - _logicalDevices = Points - .GroupBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => new MmsLogicalDeviceDirectory(x.Key, x), - StringComparer.OrdinalIgnoreCase); - - _pointsByUserReference = Points - .GroupBy(x => x.UserReference, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => x.ToList(), - StringComparer.OrdinalIgnoreCase); - - _pointsByMmsReference = Points - .GroupBy(x => x.MmsReference, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - x => x.Key, - x => x.OrderByDescending(p => p.Confidence).First(), - StringComparer.OrdinalIgnoreCase); + Rebuild(points); } - public IReadOnlyList Points { get; } + public IReadOnlyList Points { get; private set; } = Array.Empty(); public IReadOnlyDictionary LogicalDevices => _logicalDevices; public int LogicalDeviceCount => _logicalDevices.Count; public int LogicalNodeCount => _logicalDevices.Values.Sum(x => x.LogicalNodes.Count); @@ -48,6 +20,35 @@ public MmsIedModelDirectory(IEnumerable points) public int ReportAttributeCount => Points.Count(x => x.IsReportAttribute); public int ControlAttributeCount => Points.Count(x => x.IsControlAttribute); + internal int AddSupplementalPoints(IEnumerable supplementalPoints) + { + ArgumentNullException.ThrowIfNull(supplementalPoints); + + var combined = new List(Points); + var knownMmsReferences = new HashSet( + Points.Select(point => point.MmsReference), + StringComparer.OrdinalIgnoreCase); + var added = 0; + + foreach (var point in supplementalPoints) + { + if (string.IsNullOrWhiteSpace(point.Domain) || + string.IsNullOrWhiteSpace(point.MmsItemName) || + !knownMmsReferences.Add(point.MmsReference)) + { + continue; + } + + combined.Add(point); + added++; + } + + if (added > 0) + Rebuild(combined); + + return added; + } + public IReadOnlyList FindByUserReference(string reference) { var normalized = MmsFcReferenceNormalizer.NormalizeUserReference(reference); @@ -84,6 +85,29 @@ public IReadOnlyDictionary CountByFunctionalConstraint() public string Summary => $"IED directory: LD={LogicalDeviceCount}, LN={LogicalNodeCount}, FC-points={PointCount}, reportAttrs={ReportAttributeCount}, controlAttrs={ControlAttributeCount}"; + + private void Rebuild(IEnumerable points) + { + Points = points + .Where(x => !string.IsNullOrWhiteSpace(x.Domain) && !string.IsNullOrWhiteSpace(x.MmsItemName)) + .OrderBy(x => x.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.DataObjectPath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + _logicalDevices.Clear(); + foreach (var group in Points.GroupBy(x => x.Domain, StringComparer.OrdinalIgnoreCase)) + _logicalDevices[group.Key] = new MmsLogicalDeviceDirectory(group.Key, group); + + _pointsByUserReference.Clear(); + foreach (var group in Points.GroupBy(x => x.UserReference, StringComparer.OrdinalIgnoreCase)) + _pointsByUserReference[group.Key] = group.ToList(); + + _pointsByMmsReference.Clear(); + foreach (var group in Points.GroupBy(x => x.MmsReference, StringComparer.OrdinalIgnoreCase)) + _pointsByMmsReference[group.Key] = group.OrderByDescending(point => point.Confidence).First(); + } } public sealed class MmsLogicalDeviceDirectory diff --git a/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs new file mode 100644 index 00000000..f3c55d9a --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDataSetPipelinePolicy.cs @@ -0,0 +1,17 @@ +namespace AR.Iec61850.Mms; + +/// +/// Pure policy helpers for the smart DataSet-directory pipeline. Keeping the worker +/// budget calculation separate makes the negotiated-request-window invariant easy to +/// regression test without network traffic. +/// +public static class MmsSmartDataSetPipelinePolicy +{ + public static int ResolveWorkerCount(int dataSetCount, int maxConcurrency) + { + if (dataSetCount <= 0) + return 0; + + return Math.Min(dataSetCount, Math.Max(1, maxConcurrency)); + } +} diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs new file mode 100644 index 00000000..1584ecae --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryKpi.cs @@ -0,0 +1,439 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace AR.Iec61850.Mms; + +public sealed class MmsSmartDiscoveryKpiPhaseSnapshot +{ + public string Phase { get; init; } = string.Empty; + public int Requests { get; init; } + public int SuccessfulRequests { get; init; } + public int FailedRequests { get; init; } + public int DuplicateRequests { get; init; } + public double TotalLatencyMs { get; init; } + public double AverageLatencyMs { get; init; } + public double MaxLatencyMs { get; init; } +} + +/// +/// Zero-traffic observability snapshot for one smart-discovery generation. +/// Request counts come only from requests that ARSAS was already going to send. +/// Timing is measured around the existing confirmed-service call sites; no probe, +/// ping, Read, GVA, or GetNameList is added for KPI collection. +/// +public sealed class MmsSmartDiscoveryKpiSnapshot +{ + public long Generation { get; init; } + public DateTimeOffset StartedAtUtc { get; init; } + public double ElapsedMs { get; init; } + public int TotalRequests { get; init; } + public int SuccessfulRequests { get; init; } + public int FailedRequests { get; init; } + public int DuplicateRequests { get; init; } + public int PeakOutstandingRequests { get; init; } + public bool WireAccountingComplete { get; init; } = true; + public IReadOnlyList AccountingNotes { get; init; } = Array.Empty(); + public int LogicalDeviceCount { get; init; } + public int LogicalNodeCount { get; init; } + public int RawVariableCount { get; init; } + public int FcPointCount { get; init; } + public int DataSetCount { get; init; } + public int DataSetDirectoryCount { get; init; } + public int DataSetMemberCount { get; init; } + public int ReportControlCount { get; init; } + public int BufferedReportControlCount { get; init; } + public int UnbufferedReportControlCount { get; init; } + public IReadOnlyList Phases { get; init; } = Array.Empty(); + public string DeterministicSignature { get; init; } = string.Empty; + + public string Summary => + $"Smart discovery KPI: generation={Generation}, requests={TotalRequests}, success={SuccessfulRequests}, " + + $"failed={FailedRequests}, duplicates={DuplicateRequests}, peakOutstanding={PeakOutstandingRequests}, " + + $"wireAccounting={(WireAccountingComplete ? "complete" : "partial")}, elapsed={ElapsedMs:0.0} ms, " + + $"LD={LogicalDeviceCount}, LN={LogicalNodeCount}, raw={RawVariableCount}, FC-points={FcPointCount}, " + + $"datasets={DataSetCount}, datasetDirectories={DataSetDirectoryCount}, datasetMembers={DataSetMemberCount}, " + + $"RCB={ReportControlCount}, signature={DeterministicSignature}."; +} + +public sealed partial class MmsClientSession +{ + private static long _nextSmartDiscoveryKpiGeneration; + private MmsSmartDiscoveryKpiRecorder? _smartDiscoveryKpiRecorder; + + /// + /// Most recent point-in-time KPI snapshot. The deterministic signature excludes + /// wall-clock timing and worker completion order so equivalent discovery evidence + /// produces the same signature across repeat runs. + /// + public MmsSmartDiscoveryKpiSnapshot? LastSmartDiscoveryKpi + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.Snapshot(); + + private void BeginSmartDiscoveryKpiGeneration() + { + var generation = Interlocked.Increment(ref _nextSmartDiscoveryKpiGeneration); + Volatile.Write(ref _smartDiscoveryKpiRecorder, new MmsSmartDiscoveryKpiRecorder(generation)); + } + + private MmsSmartDiscoveryRequestObservation ObserveSmartDiscoveryRequest( + string phase, + string service, + string logicalKey) + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.BeginRequest(phase, service, logicalKey) + ?? MmsSmartDiscoveryRequestObservation.Noop; + + private void MarkSmartDiscoveryKpiAccountingPartial(string note) + => Volatile.Read(ref _smartDiscoveryKpiRecorder)?.MarkAccountingPartial(note); + + private void UpdateSmartDiscoveryCompleteness( + MmsDiscoverySnapshot snapshot, + MmsIedModelDirectory directory, + MmsReportInventory inventory, + IReadOnlyList dataSetDirectories) + { + var recorder = Volatile.Read(ref _smartDiscoveryKpiRecorder); + if (recorder is null) + return; + + recorder.UpdateCompleteness( + directory.LogicalDeviceCount, + directory.LogicalNodeCount, + snapshot.RawVariableCount, + directory.PointCount, + inventory.DataSets.Count, + dataSetDirectories.Count(result => result.IsSuccess), + dataSetDirectories.Where(result => result.IsSuccess).Sum(result => result.Members.Count), + inventory.ReportControls.Count, + inventory.BufferedCount, + inventory.UnbufferedCount); + } + + /// + /// Refreshes model completeness after later semantic materialization has enriched + /// the same live directory. This is local bookkeeping only and sends no MMS traffic. + /// + public void RefreshSmartDiscoveryModelKpi(MmsIedModelDirectory directory) + { + ArgumentNullException.ThrowIfNull(directory); + Volatile.Read(ref _smartDiscoveryKpiRecorder)?.UpdateModelCompleteness( + directory.LogicalDeviceCount, + directory.LogicalNodeCount, + directory.PointCount); + } + + private async Task> GetObservedSmartDataSetDirectoriesAsync( + IReadOnlyList dataSetReferences, + MmsIedModelDirectory directory, + CancellationToken cancellationToken) + { + if (dataSetReferences.Count == 0) + return Array.Empty(); + + var results = new List(dataSetReferences.Count); + foreach (var reference in dataSetReferences) + { + cancellationToken.ThrowIfCancellationRequested(); + using var observation = ObserveSmartDiscoveryRequest( + "dataset-directory", + "GetNamedVariableListAttributes", + reference); + + var result = await GetDataSetDirectoryAsync(reference, directory, cancellationToken) + .ConfigureAwait(false); + observation.Complete(result.IsSuccess); + results.Add(result); + + if (!IsMmsInitiated) + break; + } + + return results; + } +} + +internal sealed class MmsSmartDiscoveryKpiRecorder +{ + private readonly object _gate = new(); + private readonly long _startedTimestamp = Stopwatch.GetTimestamp(); + private readonly Dictionary _requestStats = new(StringComparer.Ordinal); + private readonly Dictionary _phaseStats = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _accountingNotes = new(StringComparer.Ordinal); + private int _totalRequests; + private int _successfulRequests; + private int _failedRequests; + private int _duplicateRequests; + private int _outstandingRequests; + private int _peakOutstandingRequests; + private bool _wireAccountingComplete = true; + private int _logicalDeviceCount; + private int _logicalNodeCount; + private int _rawVariableCount; + private int _fcPointCount; + private int _dataSetCount; + private int _dataSetDirectoryCount; + private int _dataSetMemberCount; + private int _reportControlCount; + private int _bufferedReportControlCount; + private int _unbufferedReportControlCount; + + public MmsSmartDiscoveryKpiRecorder(long generation) + { + Generation = generation; + StartedAtUtc = DateTimeOffset.UtcNow; + } + + public long Generation { get; } + public DateTimeOffset StartedAtUtc { get; } + + public MmsSmartDiscoveryRequestObservation BeginRequest(string phase, string service, string logicalKey) + { + var normalizedPhase = Normalize(phase, "unknown"); + var normalizedService = Normalize(service, "unknown"); + var normalizedKey = Normalize(logicalKey, ""); + var descriptor = $"{normalizedPhase}|{normalizedService}|{normalizedKey}"; + + lock (_gate) + { + _totalRequests++; + _outstandingRequests++; + _peakOutstandingRequests = Math.Max(_peakOutstandingRequests, _outstandingRequests); + + if (!_phaseStats.TryGetValue(normalizedPhase, out var phaseStats)) + { + phaseStats = new MutablePhaseStats(normalizedPhase); + _phaseStats.Add(normalizedPhase, phaseStats); + } + phaseStats.Requests++; + + if (!_requestStats.TryGetValue(descriptor, out var requestStats)) + { + requestStats = new MutableRequestStats(descriptor); + _requestStats.Add(descriptor, requestStats); + } + else + { + _duplicateRequests++; + phaseStats.DuplicateRequests++; + } + requestStats.Attempts++; + } + + return new MmsSmartDiscoveryRequestObservation(this, normalizedPhase, descriptor, Stopwatch.GetTimestamp()); + } + + public void MarkAccountingPartial(string note) + { + lock (_gate) + { + _wireAccountingComplete = false; + var normalized = Normalize(note, "unspecified unobserved request path"); + _accountingNotes.Add(normalized); + } + } + + public void UpdateCompleteness( + int logicalDeviceCount, + int logicalNodeCount, + int rawVariableCount, + int fcPointCount, + int dataSetCount, + int dataSetDirectoryCount, + int dataSetMemberCount, + int reportControlCount, + int bufferedReportControlCount, + int unbufferedReportControlCount) + { + lock (_gate) + { + _logicalDeviceCount = Math.Max(0, logicalDeviceCount); + _logicalNodeCount = Math.Max(0, logicalNodeCount); + _rawVariableCount = Math.Max(0, rawVariableCount); + _fcPointCount = Math.Max(0, fcPointCount); + _dataSetCount = Math.Max(0, dataSetCount); + _dataSetDirectoryCount = Math.Max(0, dataSetDirectoryCount); + _dataSetMemberCount = Math.Max(0, dataSetMemberCount); + _reportControlCount = Math.Max(0, reportControlCount); + _bufferedReportControlCount = Math.Max(0, bufferedReportControlCount); + _unbufferedReportControlCount = Math.Max(0, unbufferedReportControlCount); + } + } + + public void UpdateModelCompleteness(int logicalDeviceCount, int logicalNodeCount, int fcPointCount) + { + lock (_gate) + { + _logicalDeviceCount = Math.Max(0, logicalDeviceCount); + _logicalNodeCount = Math.Max(0, logicalNodeCount); + _fcPointCount = Math.Max(0, fcPointCount); + } + } + + public MmsSmartDiscoveryKpiSnapshot Snapshot() + { + lock (_gate) + { + var phases = _phaseStats.Values + .OrderBy(phase => phase.Phase, StringComparer.OrdinalIgnoreCase) + .Select(phase => new MmsSmartDiscoveryKpiPhaseSnapshot + { + Phase = phase.Phase, + Requests = phase.Requests, + SuccessfulRequests = phase.SuccessfulRequests, + FailedRequests = phase.FailedRequests, + DuplicateRequests = phase.DuplicateRequests, + TotalLatencyMs = phase.TotalLatencyMs, + AverageLatencyMs = phase.Requests == 0 ? 0 : phase.TotalLatencyMs / phase.Requests, + MaxLatencyMs = phase.MaxLatencyMs + }) + .ToArray(); + + return new MmsSmartDiscoveryKpiSnapshot + { + Generation = Generation, + StartedAtUtc = StartedAtUtc, + ElapsedMs = Stopwatch.GetElapsedTime(_startedTimestamp).TotalMilliseconds, + TotalRequests = _totalRequests, + SuccessfulRequests = _successfulRequests, + FailedRequests = _failedRequests, + DuplicateRequests = _duplicateRequests, + PeakOutstandingRequests = _peakOutstandingRequests, + WireAccountingComplete = _wireAccountingComplete, + AccountingNotes = _accountingNotes.OrderBy(note => note, StringComparer.Ordinal).ToArray(), + LogicalDeviceCount = _logicalDeviceCount, + LogicalNodeCount = _logicalNodeCount, + RawVariableCount = _rawVariableCount, + FcPointCount = _fcPointCount, + DataSetCount = _dataSetCount, + DataSetDirectoryCount = _dataSetDirectoryCount, + DataSetMemberCount = _dataSetMemberCount, + ReportControlCount = _reportControlCount, + BufferedReportControlCount = _bufferedReportControlCount, + UnbufferedReportControlCount = _unbufferedReportControlCount, + Phases = phases, + DeterministicSignature = BuildDeterministicSignature() + }; + } + } + + internal void CompleteRequest(string phase, string descriptor, long startedTimestamp, bool success) + { + var elapsedMs = Stopwatch.GetElapsedTime(startedTimestamp).TotalMilliseconds; + lock (_gate) + { + _outstandingRequests = Math.Max(0, _outstandingRequests - 1); + if (success) + _successfulRequests++; + else + _failedRequests++; + + if (_requestStats.TryGetValue(descriptor, out var requestStats)) + { + if (success) + requestStats.SuccessfulAttempts++; + else + requestStats.FailedAttempts++; + } + + if (_phaseStats.TryGetValue(phase, out var phaseStats)) + { + if (success) + phaseStats.SuccessfulRequests++; + else + phaseStats.FailedRequests++; + phaseStats.TotalLatencyMs += elapsedMs; + phaseStats.MaxLatencyMs = Math.Max(phaseStats.MaxLatencyMs, elapsedMs); + } + } + } + + private string BuildDeterministicSignature() + { + var builder = new StringBuilder(); + foreach (var request in _requestStats.Values.OrderBy(item => item.Descriptor, StringComparer.Ordinal)) + { + builder.Append(request.Descriptor) + .Append("|attempts=").Append(request.Attempts) + .Append("|ok=").Append(request.SuccessfulAttempts) + .Append("|fail=").Append(request.FailedAttempts) + .Append('\n'); + } + + builder.Append("accounting|complete=").Append(_wireAccountingComplete); + foreach (var note in _accountingNotes.OrderBy(note => note, StringComparer.Ordinal)) + builder.Append("|note=").Append(note); + builder.Append('\n'); + + builder.Append("model|ld=").Append(_logicalDeviceCount) + .Append("|ln=").Append(_logicalNodeCount) + .Append("|raw=").Append(_rawVariableCount) + .Append("|fc=").Append(_fcPointCount) + .Append("|ds=").Append(_dataSetCount) + .Append("|dsdir=").Append(_dataSetDirectoryCount) + .Append("|dsmembers=").Append(_dataSetMemberCount) + .Append("|rcb=").Append(_reportControlCount) + .Append("|brcb=").Append(_bufferedReportControlCount) + .Append("|urcb=").Append(_unbufferedReportControlCount); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string Normalize(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) + ? fallback + : value.Trim().Replace('\r', ' ').Replace('\n', ' ').ToLowerInvariant(); + + private sealed class MutableRequestStats + { + public MutableRequestStats(string descriptor) => Descriptor = descriptor; + public string Descriptor { get; } + public int Attempts { get; set; } + public int SuccessfulAttempts { get; set; } + public int FailedAttempts { get; set; } + } + + private sealed class MutablePhaseStats + { + public MutablePhaseStats(string phase) => Phase = phase; + public string Phase { get; } + public int Requests { get; set; } + public int SuccessfulRequests { get; set; } + public int FailedRequests { get; set; } + public int DuplicateRequests { get; set; } + public double TotalLatencyMs { get; set; } + public double MaxLatencyMs { get; set; } + } +} + +internal sealed class MmsSmartDiscoveryRequestObservation : IDisposable +{ + public static MmsSmartDiscoveryRequestObservation Noop { get; } = new(null, string.Empty, string.Empty, 0); + + private readonly MmsSmartDiscoveryKpiRecorder? _recorder; + private readonly string _phase; + private readonly string _descriptor; + private readonly long _startedTimestamp; + private int _completed; + + public MmsSmartDiscoveryRequestObservation( + MmsSmartDiscoveryKpiRecorder? recorder, + string phase, + string descriptor, + long startedTimestamp) + { + _recorder = recorder; + _phase = phase; + _descriptor = descriptor; + _startedTimestamp = startedTimestamp; + } + + public void Complete(bool success) + { + if (_recorder is null || Interlocked.Exchange(ref _completed, 1) != 0) + return; + + _recorder.CompleteRequest(_phase, _descriptor, _startedTimestamp, success); + } + + public void Dispose() => Complete(success: false); +} diff --git a/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs new file mode 100644 index 00000000..0e6ddaae --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSmartDiscoveryPolicy.cs @@ -0,0 +1,221 @@ +namespace AR.Iec61850.Mms; + +/// +/// Pure scheduling and type-tree rules shared by smart discovery. Keeping these +/// decisions side-effect free makes them deterministic, cheap to test, and safe to +/// reuse from SCL-assisted and live-only orchestration. +/// +internal static class MmsSmartDiscoveryPolicy +{ + public static int ResolveWindow(int requested, int unknownPeerCap, int? negotiatedMaxOutstandingCalling) + { + var requestedBound = Math.Clamp(requested, 1, 32); + var unknownBound = Math.Clamp(unknownPeerCap, 1, 16); + + if (negotiatedMaxOutstandingCalling is > 0) + return Math.Max(1, Math.Min(requestedBound, negotiatedMaxOutstandingCalling.Value)); + + return Math.Min(requestedBound, unknownBound); + } + + /// + /// Selects the live domain set independently from any SCL hint. This is important: + /// SCL may prioritize online work, but it must never manufacture or suppress live + /// MMS evidence. Case-only collisions are collapsed deterministically because the + /// existing MMS directory model is case-insensitive and cannot represent both + /// without a dictionary collision. + /// + public static string[] SelectPublishedDomains(IEnumerable observedDomains, int maxDomains) + { + ArgumentNullException.ThrowIfNull(observedDomains); + + return observedDomains + .Where(domain => !string.IsNullOrWhiteSpace(domain)) + .Select(domain => domain.Trim()) + .GroupBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderBy(domain => domain, StringComparer.Ordinal).First()) + .OrderBy(domain => domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(domain => domain, StringComparer.Ordinal) + .Take(Math.Clamp(maxDomains, 1, 4096)) + .ToArray(); + } + + /// + /// Reorders an already-selected live domain set for scheduling only. Exact SCL + /// domain hints are attempted first, while unknown hints are ignored and every + /// selected live domain remains present exactly once. + /// + public static string[] OrderDomainsForScheduling( + IReadOnlyList selectedLiveDomains, + IReadOnlyList? priorityDomains) + { + ArgumentNullException.ThrowIfNull(selectedLiveDomains); + if (selectedLiveDomains.Count <= 1 || priorityDomains is null || priorityDomains.Count == 0) + return selectedLiveDomains.ToArray(); + + var live = new HashSet(selectedLiveDomains, StringComparer.Ordinal); + var emitted = new HashSet(StringComparer.Ordinal); + var ordered = new List(selectedLiveDomains.Count); + + foreach (var hint in priorityDomains) + { + if (string.IsNullOrWhiteSpace(hint)) + continue; + + var normalized = hint.Trim(); + if (live.Contains(normalized) && emitted.Add(normalized)) + ordered.Add(normalized); + } + + foreach (var domain in selectedLiveDomains) + { + if (emitted.Add(domain)) + ordered.Add(domain); + } + + return ordered.ToArray(); + } +} + +internal static class MmsSmartTypeProbePolicy +{ + private const char CompositeKeySeparator = '\u001F'; + + /// + /// Intersects supplied LN-root candidates with the logical nodes proven by the + /// current live directory. Hints can affect order at higher layers, but a GVA must + /// never be sent merely because a stale SCL/caller candidate names a non-live LN. + /// Returned references use canonical live directory spelling and deterministic + /// LD/LN order. + /// + public static MmsObjectReference[] SelectLiveLogicalNodeRoots( + MmsIedModelDirectory directory, + IEnumerable candidates) + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentNullException.ThrowIfNull(candidates); + + var requested = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in candidates) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || + string.IsNullOrWhiteSpace(reference.Item) || + reference.Item.Contains('$', StringComparison.Ordinal)) + { + continue; + } + + requested.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + if (requested.Count == 0) + return Array.Empty(); + + return directory.LogicalDevices.Values + .OrderBy(device => device.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(device => device.Name, StringComparer.Ordinal) + .SelectMany(device => device.LogicalNodes.Values + .OrderBy(node => node.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(node => node.Name, StringComparer.Ordinal) + .Where(node => requested.Contains(BuildReferenceKey(device.Name, node.Name))) + .Select(node => new MmsObjectReference(device.Name, node.Name, string.Empty))) + .ToArray(); + } + + public static MmsObjectReference BuildDataObjectRoot(MmsFcResolvedPoint point) + { + ArgumentNullException.ThrowIfNull(point); + + var parts = Split(point.MmsItemName); + var item = parts.Length >= 3 + ? string.Join('$', parts.Take(3)) + : point.MmsItemName; + return new MmsObjectReference(point.Domain, item, point.FunctionalConstraint); + } + + /// + /// Builds exact fallback probes without ever reissuing an exact GVA reference that + /// was already attempted earlier in the same hierarchy ladder. This matters for + /// flat inventories containing LN$FC$DO roots: after a DO-root GVA fails or is + /// shallow, treating that same root as a leaf fallback would otherwise send the + /// identical request twice with no new evidence boundary. + /// + public static MmsObjectReference[] BuildUnprobedExactFallbacks( + IEnumerable unresolvedPoints, + IEnumerable alreadyProbed) + { + ArgumentNullException.ThrowIfNull(unresolvedPoints); + ArgumentNullException.ThrowIfNull(alreadyProbed); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var reference in alreadyProbed) + { + if (string.IsNullOrWhiteSpace(reference.Domain) || string.IsNullOrWhiteSpace(reference.Item)) + continue; + seen.Add(BuildReferenceKey(reference.Domain.Trim(), reference.Item.Trim())); + } + + var fallback = new List(); + foreach (var point in unresolvedPoints + .Where(point => !string.IsNullOrWhiteSpace(point.Domain) && + !string.IsNullOrWhiteSpace(point.MmsItemName)) + .OrderBy(point => point.Domain, StringComparer.OrdinalIgnoreCase) + .ThenBy(point => point.MmsItemName, StringComparer.OrdinalIgnoreCase)) + { + var reference = point.ToObjectReference(); + var key = BuildReferenceKey(reference.Domain, reference.Item); + if (!seen.Add(key)) + continue; + + fallback.Add(reference); + } + + return fallback.ToArray(); + } + + /// + /// Returns true only when the supplied GVA result can prove the requested MMS + /// item through its TypeSpecification hierarchy. A successful but shallow result + /// is deliberately not treated as coverage for descendants. + /// + public static bool Covers( + MmsVariableAccessAttributesResult result, + string targetMmsItemName) + { + if (!result.IsSuccess || result.TypeSpecification is null || string.IsNullOrWhiteSpace(targetMmsItemName)) + return false; + + var rootParts = Split(result.Reference.Item); + var targetParts = Split(targetMmsItemName); + if (rootParts.Length == 0 || targetParts.Length < rootParts.Length) + return false; + + for (var index = 0; index < rootParts.Length; index++) + { + if (!string.Equals(rootParts[index], targetParts[index], StringComparison.OrdinalIgnoreCase)) + return false; + } + + var current = result.TypeSpecification; + for (var index = rootParts.Length; index < targetParts.Length; index++) + { + var part = targetParts[index]; + var next = current.Children.FirstOrDefault(child => + string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase)); + if (next is null) + return false; + + current = next; + } + + return true; + } + + private static string BuildReferenceKey(string domain, string item) + => string.Concat(domain ?? string.Empty, CompositeKeySeparator, item ?? string.Empty); + + private static string[] Split(string value) + => (value ?? string.Empty).Split( + '$', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/AR.Iec61850/Osi/TpktClient.cs b/src/AR.Iec61850/Osi/TpktClient.cs index ead7fe69..a4527272 100644 --- a/src/AR.Iec61850/Osi/TpktClient.cs +++ b/src/AR.Iec61850/Osi/TpktClient.cs @@ -5,6 +5,7 @@ namespace AR.Iec61850.Osi; public sealed class TpktClient : IAsyncDisposable { + private readonly SemaphoreSlim _sendGate = new(1, 1); private TcpClient? _tcpClient; private NetworkStream? _stream; @@ -43,20 +44,29 @@ public async Task ConnectAsync(string host, int port, TimeSpan timeout, Cancella public async Task SendTpktAsync(ReadOnlyMemory payload, CancellationToken cancellationToken) { - if (_stream == null) - throw new InvalidOperationException("TPKT stream is not connected."); - if (payload.Length > ushort.MaxValue - 4) throw new ArgumentOutOfRangeException(nameof(payload), "TPKT payload is too large."); - var frame = new byte[payload.Length + 4]; - frame[0] = 0x03; - frame[1] = 0x00; - frame[2] = (byte)(frame.Length >> 8); - frame[3] = (byte)(frame.Length & 0xFF); - payload.CopyTo(frame.AsMemory(4)); - - await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + // Serialize only complete frame writes. Building the frame inside the gate keeps + // peak allocation bounded to one outbound TPKT frame even when many confirmed + // MMS operations are outstanding concurrently. + await _sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var stream = _stream ?? throw new InvalidOperationException("TPKT stream is not connected."); + var frame = new byte[payload.Length + 4]; + frame[0] = 0x03; + frame[1] = 0x00; + frame[2] = (byte)(frame.Length >> 8); + frame[3] = (byte)(frame.Length & 0xFF); + payload.CopyTo(frame.AsMemory(4)); + + await stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendGate.Release(); + } } public async Task ReceiveTpktAsync(CancellationToken cancellationToken) diff --git a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs index 93ad0705..ccd70779 100644 --- a/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/AuthoritativeLiveIedSclExporter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Xml.Linq; using AR.Iec61850.Discovery; using AR.Iec61850.Mms; @@ -22,13 +23,28 @@ public static LiveIedSclExportResult WriteFiles( options ??= new LiveIedSclExportOptions(); var result = LiveIedSclExporter.WriteFiles(model, sclPath, options); - var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); - if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) - document = ApplyIdentity(document, model, options.IedNameOverride); - - document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); - document.Save(result.SclPath); - return result; + try + { + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + if (!string.IsNullOrWhiteSpace(options.IedNameOverride)) + document = ApplyIdentity(document, model, options.IedNameOverride); + + document = ApplyReportControlConfiguration(document, model, options.ResolvedSchemaProfile); + ValidateExportGraph(document); + document.Save(result.SclPath); + return WithReportControlCount(result, document.Descendants(Scl + "ReportControl").Count()); + } + catch + { + // Authoritative export is all-or-nothing. The generic exporter writes its + // artifacts first, so remove them if authoritative identity/RCB/DataSet/FCDA + // validation rejects the generated graph. Never leave a half-valid CID behind. + DeleteIfExists(result.SclPath); + DeleteIfExists(result.ReportPath); + DeleteIfExists(result.SummaryPath); + DeleteIfExists(result.ExcludedAttributesPath); + throw; + } } public static XDocument ApplyIdentity( @@ -93,106 +109,305 @@ public static XDocument ApplyReportControlConfiguration( ArgumentNullException.ThrowIfNull(schema); var document = new XDocument(source); - var reportControls = model.ReportControls.ToArray(); - foreach (var element in document.Descendants(Scl + "ReportControl")) + var runtimeControls = model.ReportControls.ToArray(); + var projections = LiveRcbLogicalGroupProjector.Project(runtimeControls); + + foreach (var projection in projections) { - var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); - var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; - var matches = reportControls - .Where(control => - control.Name.Equals(name, StringComparison.OrdinalIgnoreCase) && - control.Buffered == buffered) - .ToArray(); - var modelControl = matches.Length == 1 - ? matches[0] - : reportControls.Length == 1 - ? reportControls[0] - : null; - if (modelControl is null) - continue; + var runtimeElements = new List(projection.RuntimeInstances.Count); + foreach (var runtimeControl in projection.RuntimeInstances) + { + var matches = document.Descendants(Scl + "ReportControl") + .Where(element => MatchesRuntimeControl(element, runtimeControl)) + .ToArray(); + if (matches.Length != 1) + { + throw new InvalidDataException( + $"Live RCB '{runtimeControl.Reference}' matched {matches.Length} generated ReportControl element(s); exactly one is required before logical projection."); + } + runtimeElements.Add(matches[0]); + } - // MMS discovery returns a concrete RCB object name. IEC 61850-6 defines - // ReportControl@indexed with a default value of true; if the attribute is - // omitted, an engineering tool appends another two-digit instance suffix. - // Therefore A_BRCB_1201 would become the invalid A_BRCB_120101. Preserve - // the proven live object exactly as one non-indexed instance. - element.SetAttributeValue("name", SafeXmlName(modelControl.Name)); - element.SetAttributeValue("indexed", "false"); - var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled"); - rptEnabled.SetAttributeValue("max", "1"); - foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray()) - clientLn.Remove(); - if (rptEnabled.Parent is null) - element.Add(rptEnabled); - - var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); - var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); - triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); - triggerElement.SetAttributeValue("qchg", XmlBool(trigger.QualityChange)); - triggerElement.SetAttributeValue("dupd", XmlBool(trigger.DataUpdate)); - triggerElement.SetAttributeValue("period", XmlBool(trigger.Integrity)); - triggerElement.SetAttributeValue( - "gi", - schema.SupportsTriggerGi ? XmlBool(trigger.GeneralInterrogation) : null); - if (triggerElement.Parent is null) - element.Add(triggerElement); - - var optional = MmsReportControlFieldCodec.DecodeOptionalFields(modelControl.OptionalFields); - var optionalElement = element.Element(Scl + "OptFields") ?? new XElement(Scl + "OptFields"); - optionalElement.SetAttributeValue("seqNum", XmlBool(optional.SequenceNumber)); - optionalElement.SetAttributeValue("timeStamp", XmlBool(optional.ReportTimestamp)); - optionalElement.SetAttributeValue("reasonCode", XmlBool(optional.ReasonForInclusion)); - optionalElement.SetAttributeValue("dataSet", XmlBool(optional.DataSetName)); - optionalElement.SetAttributeValue("dataRef", XmlBool(optional.DataReference)); - optionalElement.SetAttributeValue("bufOvfl", XmlBool(optional.BufferOverflow)); - optionalElement.SetAttributeValue("entryID", XmlBool(optional.EntryId)); - optionalElement.SetAttributeValue("configRef", XmlBool(optional.ConfigurationRevision)); - optionalElement.SetAttributeValue( - "segmentation", - schema.IsEdition2 ? XmlBool(optional.Segmentation) : null); - if (optionalElement.Parent is null) - element.Add(optionalElement); + if (runtimeElements.Distinct(ReferenceEqualityComparer.Instance).Count() != runtimeElements.Count) + throw new InvalidDataException("Multiple live RCB instances resolved to the same generated ReportControl element."); + + if (projection.Indexed && runtimeElements.Select(element => element.Parent).Distinct(ReferenceEqualityComparer.Instance).Count() != 1) + { + throw new InvalidDataException( + $"Indexed RCB group '{projection.LogicalName}' spans multiple logical nodes in the generated SCL."); + } + + var element = runtimeElements[0]; + foreach (var redundant in runtimeElements.Skip(1)) + redundant.Remove(); + + ApplyProjectedReportControlConfiguration(element, projection, schema); } var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); if (confReportControl is not null) - confReportControl.SetAttributeValue("max", reportControls.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); + confReportControl.SetAttributeValue("max", projections.Count.ToString(CultureInfo.InvariantCulture)); - ValidateReportControlIdentity(document, reportControls); + ValidateReportControlIdentity(document, projections); return document; } + private static void ApplyProjectedReportControlConfiguration( + XElement element, + LiveRcbLogicalGroupProjector.Projection projection, + SclSchemaProfileDescriptor schema) + { + var modelControl = projection.Representative; + element.SetAttributeValue("name", SafeXmlName(projection.LogicalName)); + element.SetAttributeValue("indexed", projection.Indexed ? "true" : "false"); + element.SetAttributeValue("rptID", string.IsNullOrWhiteSpace(projection.ReportId) ? null : projection.ReportId); + + var rptEnabled = element.Element(Scl + "RptEnabled") ?? new XElement(Scl + "RptEnabled"); + rptEnabled.SetAttributeValue("max", projection.MaxInstances.ToString(CultureInfo.InvariantCulture)); + foreach (var clientLn in rptEnabled.Elements(Scl + "ClientLN").ToArray()) + clientLn.Remove(); + if (rptEnabled.Parent is null) + element.Add(rptEnabled); + + var trigger = MmsReportControlFieldCodec.DecodeTriggerOptions(modelControl.TriggerOptions); + var triggerElement = element.Element(Scl + "TrgOps") ?? new XElement(Scl + "TrgOps"); + triggerElement.SetAttributeValue("dchg", XmlBool(trigger.DataChange)); + triggerElement.SetAttributeValue("qchg", XmlBool(trigger.QualityChange)); + triggerElement.SetAttributeValue("dupd", XmlBool(trigger.DataUpdate)); + triggerElement.SetAttributeValue("period", XmlBool(trigger.Integrity)); + triggerElement.SetAttributeValue( + "gi", + schema.SupportsTriggerGi ? XmlBool(trigger.GeneralInterrogation) : null); + if (triggerElement.Parent is null) + element.Add(triggerElement); + + var optional = MmsReportControlFieldCodec.DecodeOptionalFields(modelControl.OptionalFields); + var optionalElement = element.Element(Scl + "OptFields") ?? new XElement(Scl + "OptFields"); + optionalElement.SetAttributeValue("seqNum", XmlBool(optional.SequenceNumber)); + optionalElement.SetAttributeValue("timeStamp", XmlBool(optional.ReportTimestamp)); + optionalElement.SetAttributeValue("reasonCode", XmlBool(optional.ReasonForInclusion)); + optionalElement.SetAttributeValue("dataSet", XmlBool(optional.DataSetName)); + optionalElement.SetAttributeValue("dataRef", XmlBool(optional.DataReference)); + optionalElement.SetAttributeValue("bufOvfl", XmlBool(optional.BufferOverflow)); + optionalElement.SetAttributeValue("entryID", XmlBool(optional.EntryId)); + optionalElement.SetAttributeValue("configRef", XmlBool(optional.ConfigurationRevision)); + optionalElement.SetAttributeValue( + "segmentation", + schema.IsEdition2 ? XmlBool(optional.Segmentation) : null); + if (optionalElement.Parent is null) + element.Add(optionalElement); + } + private static void ValidateReportControlIdentity( XDocument document, - IReadOnlyCollection reportControls) + IReadOnlyList projections) { var exported = document.Descendants(Scl + "ReportControl").ToArray(); - if (exported.Length != reportControls.Count) + if (exported.Length != projections.Count) { throw new InvalidDataException( - $"Generated SCL contains {exported.Length} ReportControl element(s), but live discovery contains {reportControls.Count}."); + $"Generated SCL contains {exported.Length} logical ReportControl element(s), but live evidence projects to {projections.Count}."); } - foreach (var modelControl in reportControls) + foreach (var projection in projections) { var matches = exported.Where(element => - string.Equals((string?)element.Attribute("name"), SafeXmlName(modelControl.Name), StringComparison.Ordinal) && - string.Equals((string?)element.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)).ToArray(); + MatchesProjectedControl(element, projection)) + .ToArray(); if (matches.Length != 1) { throw new InvalidDataException( - $"Live RCB '{modelControl.Name}' was not exported exactly once as indexed=false."); + $"Logical RCB '{projection.LogicalName}' was not exported exactly once in its authoritative LD/LN context."); + } + + var expectedIndexed = projection.Indexed ? "true" : "false"; + if (!string.Equals((string?)matches[0].Attribute("indexed"), expectedIndexed, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException( + $"Logical RCB '{projection.LogicalName}' must be exported with indexed={expectedIndexed}."); } var rptEnabled = matches[0].Element(Scl + "RptEnabled"); - if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), "1", StringComparison.Ordinal)) + var expectedMax = projection.MaxInstances.ToString(CultureInfo.InvariantCulture); + if (rptEnabled is null || !string.Equals((string?)rptEnabled.Attribute("max"), expectedMax, StringComparison.Ordinal)) { throw new InvalidDataException( - $"Live RCB '{modelControl.Name}' must be exported with RptEnabled max=1."); + $"Logical RCB '{projection.LogicalName}' must be exported with RptEnabled max={expectedMax}."); + } + + if (projection.Indexed) + { + foreach (var runtime in projection.RuntimeInstances) + { + if (string.Equals(runtime.Name, projection.LogicalName, StringComparison.OrdinalIgnoreCase)) + continue; + if (exported.Any(element => MatchesRuntimeControl(element, runtime))) + { + throw new InvalidDataException( + $"Concrete runtime RCB '{runtime.Name}' remained in SCL after projection to indexed logical control '{projection.LogicalName}'."); + } + } } } } + private static bool MatchesRuntimeControl(XElement element, LiveIedReportControlModel control) + { + var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); + if (!name.Equals(SafeXmlName(control.Name), StringComparison.OrdinalIgnoreCase)) + return false; + + var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; + return buffered == control.Buffered && MatchesControlContext(element, control.Domain, control.LogicalNode); + } + + private static bool MatchesProjectedControl( + XElement element, + LiveRcbLogicalGroupProjector.Projection projection) + { + var name = ((string?)element.Attribute("name") ?? string.Empty).Trim(); + if (!name.Equals(SafeXmlName(projection.LogicalName), StringComparison.OrdinalIgnoreCase)) + return false; + + var buffered = bool.TryParse((string?)element.Attribute("buffered"), out var parsedBuffered) && parsedBuffered; + return buffered == projection.Representative.Buffered && + MatchesControlContext( + element, + projection.Representative.Domain, + projection.Representative.LogicalNode); + } + + private static bool MatchesControlContext(XElement element, string domain, string logicalNode) + { + var lDevice = element.Ancestors(Scl + "LDevice").FirstOrDefault(); + var ln = element.Ancestors().FirstOrDefault(candidate => candidate.Name == Scl + "LN0" || candidate.Name == Scl + "LN"); + if (lDevice is null || ln is null) + return false; + + if (!MatchesLogicalNode(ln, logicalNode)) + return false; + + var targetDomain = domain.Trim(); + var inst = ((string?)lDevice.Attribute("inst") ?? string.Empty).Trim(); + var explicitLdName = ((string?)lDevice.Attribute("ldName") ?? string.Empty).Trim(); + var iedName = ((string?)lDevice.Ancestors(Scl + "IED").FirstOrDefault()?.Attribute("name") ?? string.Empty).Trim(); + var implicitDomain = $"{iedName}{inst}"; + + return targetDomain.Equals(inst, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(explicitLdName) && targetDomain.Equals(explicitLdName, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(iedName) && targetDomain.Equals(implicitDomain, StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesLogicalNode(XElement element, string logicalNode) + { + var target = logicalNode.Trim(); + if (element.Name == Scl + "LN0") + return target.Equals("LLN0", StringComparison.OrdinalIgnoreCase); + + var prefix = ((string?)element.Attribute("prefix") ?? string.Empty).Trim(); + var lnClass = ((string?)element.Attribute("lnClass") ?? string.Empty).Trim(); + var inst = ((string?)element.Attribute("inst") ?? string.Empty).Trim(); + return target.Equals($"{prefix}{lnClass}{inst}", StringComparison.OrdinalIgnoreCase); + } + + private static void ValidateExportGraph(XDocument document) + { + foreach (var reportControl in document.Descendants(Scl + "ReportControl")) + { + var dataSetName = ((string?)reportControl.Attribute("datSet") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(dataSetName)) + continue; + + var logicalNode = reportControl.Ancestors() + .FirstOrDefault(element => element.Name == Scl + "LN0" || element.Name == Scl + "LN") + ?? throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' is not contained by an LN/LN0 element."); + + var dataSets = logicalNode.Elements(Scl + "DataSet") + .Where(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + dataSetName, + StringComparison.Ordinal)) + .ToArray(); + if (dataSets.Length != 1) + { + throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' references DataSet '{dataSetName}', but the generated SCL contains {dataSets.Length} matching DataSet element(s) in the same logical node."); + } + + var members = dataSets[0].Elements(Scl + "FCDA").ToArray(); + if (members.Length == 0) + { + throw new InvalidDataException( + $"ReportControl '{(string?)reportControl.Attribute("name")}' references DataSet '{dataSetName}', but that DataSet contains no valid FCDA members."); + } + + foreach (var fcda in members) + { + RequireFcdaAttribute(fcda, "ldInst", dataSetName); + RequireFcdaAttribute(fcda, "lnClass", dataSetName); + RequireFcdaAttribute(fcda, "doName", dataSetName); + RequireFcdaAttribute(fcda, "fc", dataSetName); + } + } + } + + private static void RequireFcdaAttribute(XElement fcda, string attributeName, string dataSetName) + { + if (!string.IsNullOrWhiteSpace(((string?)fcda.Attribute(attributeName) ?? string.Empty).Trim())) + return; + + throw new InvalidDataException( + $"DataSet '{dataSetName}' contains an FCDA without required '{attributeName}' identity."); + } + + private static LiveIedSclExportResult WithReportControlCount( + LiveIedSclExportResult source, + int logicalReportControlCount) + => new() + { + SchemaVersion = source.SchemaVersion, + GeneratedAtUtc = source.GeneratedAtUtc, + Profile = source.Profile, + SclSchema = source.SclSchema, + SclPath = source.SclPath, + ReportPath = source.ReportPath, + SummaryPath = source.SummaryPath, + ExcludedAttributesPath = source.ExcludedAttributesPath, + LogicalDeviceCount = source.LogicalDeviceCount, + LogicalNodeCount = source.LogicalNodeCount, + DataSetCount = source.DataSetCount, + ReportControlCount = logicalReportControlCount, + GooseControlBlockCount = source.GooseControlBlockCount, + SampledValueControlBlockCount = source.SampledValueControlBlockCount, + SettingGroupControlCount = source.SettingGroupControlCount, + LogControlCount = source.LogControlCount, + LNodeTypeCount = source.LNodeTypeCount, + DoTypeCount = source.DoTypeCount, + DaTypeCount = source.DaTypeCount, + EnumTypeCount = source.EnumTypeCount, + Warnings = source.Warnings, + ExcludedAttributes = source.ExcludedAttributes, + DataSetMappings = source.DataSetMappings, + ReportMappings = source.ReportMappings, + ControlBlockMappings = source.ControlBlockMappings + }; + + private static void DeleteIfExists(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return; + + try + { + File.Delete(path); + } + catch + { + // Keep the original authoritative-validation exception as the primary failure. + } + } + private static string XmlBool(bool value) => value ? "true" : "false"; private static string MatchMmsDomain( @@ -267,4 +482,4 @@ private static string SafeXmlName(string value) return "LIVE_IED"; return char.IsLetter(result[0]) || result[0] == '_' ? result : $"_{result}"; } -} \ No newline at end of file +} diff --git a/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs new file mode 100644 index 00000000..5e1d1a69 --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/CanonicalLiveIedSclExporter.cs @@ -0,0 +1,480 @@ +using System.Globalization; +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using AR.Iec61850.Scl; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Interoperable export boundary for a canonical live IED snapshot. All connection +/// addressing comes from the accepted-association evidence stored in the model. +/// No device-specific AP-title or selector default is invented here. +/// +public static class CanonicalLiveIedSclExporter +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + public static LiveIedSclExportResult WriteFiles( + LiveIedCanonicalModel canonical, + string sclPath, + SclSchemaProfile schemaProfile = SclSchemaProfile.Edition2V31, + string profile = "safe-connection") + { + ArgumentNullException.ThrowIfNull(canonical); + ValidateCanonicalCommunication(canonical); + + var communication = canonical.Communication; + var association = communication.Association; + LiveIedSclExportResult? result = null; + + try + { + result = AuthoritativeLiveIedSclExporter.WriteFiles( + canonical.Discovery, + sclPath, + new LiveIedSclExportOptions + { + Profile = profile, + SchemaProfile = schemaProfile, + SubNetworkName = string.IsNullOrWhiteSpace(communication.SubNetworkName) + ? "StationBus" + : communication.SubNetworkName.Trim(), + IpAddress = communication.Host.Trim(), + IedNameOverride = canonical.IedName, + IpSubnet = communication.IpSubnet?.Trim() ?? string.Empty, + IpGateway = communication.IpGateway?.Trim() ?? string.Empty, + OsiApTitle = association.ApTitle.Trim(), + OsiAeQualifier = association.AeQualifier!.Value.ToString(CultureInfo.InvariantCulture), + OsiPsel = association.PresentationSelector.Trim(), + OsiSsel = association.SessionSelector.Trim(), + OsiTsel = association.TransportSelector.Trim(), + IncludeDefaultOsiParameters = true + }); + + var document = XDocument.Load(result.SclPath, LoadOptions.PreserveWhitespace); + ApplyCanonicalCommunication(document, canonical); + PreserveRuntimeServiceCapacity(document, canonical.Discovery); + ApplyCanonicalInstanceValues(document, canonical); + ValidateRoundTripAssociation(document, canonical); + document.Save(result.SclPath); + return result; + } + catch + { + DeleteIfExists(sclPath); + if (result is not null) + { + DeleteIfExists(result.ReportPath); + DeleteIfExists(result.SummaryPath); + DeleteIfExists(result.ExcludedAttributesPath); + } + throw; + } + } + + public static void ValidateCanonicalCommunication(LiveIedCanonicalModel canonical) + { + ArgumentNullException.ThrowIfNull(canonical); + var communication = canonical.Communication; + var association = communication.Association; + var errors = new List(); + + if (string.IsNullOrWhiteSpace(canonical.IedName)) + errors.Add("Canonical IED identity is empty."); + if (string.IsNullOrWhiteSpace(canonical.AccessPointName)) + errors.Add("Canonical access-point identity is empty."); + if (string.IsNullOrWhiteSpace(communication.Host)) + errors.Add("Canonical communication evidence has no IP/host endpoint."); + if (string.IsNullOrWhiteSpace(association.ApTitle)) + errors.Add("Canonical communication evidence has no accepted remote OSI-AP-Title."); + if (association.AeQualifier is not (>= 0 and <= 65535)) + errors.Add("Canonical communication evidence has no valid accepted remote OSI-AE-Qualifier."); + if (string.IsNullOrWhiteSpace(association.PresentationSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-PSEL."); + if (string.IsNullOrWhiteSpace(association.SessionSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-SSEL."); + if (string.IsNullOrWhiteSpace(association.TransportSelector)) + errors.Add("Canonical communication evidence has no accepted remote OSI-TSEL."); + + if (errors.Count > 0) + throw new InvalidDataException(string.Join(" | ", errors)); + } + + private static void ApplyCanonicalCommunication(XDocument document, LiveIedCanonicalModel canonical) + { + var connectedAp = document.Descendants(Scl + "ConnectedAP").SingleOrDefault() + ?? throw new InvalidDataException("Generated SCL must contain exactly one ConnectedAP."); + connectedAp.SetAttributeValue("iedName", canonical.IedName); + connectedAp.SetAttributeValue("apName", canonical.AccessPointName); + + var subNetwork = connectedAp.Parent; + if (subNetwork is not null && subNetwork.Name == Scl + "SubNetwork") + { + subNetwork.SetAttributeValue( + "name", + string.IsNullOrWhiteSpace(canonical.Communication.SubNetworkName) + ? "StationBus" + : canonical.Communication.SubNetworkName.Trim()); + subNetwork.SetAttributeValue("type", "8-MMS"); + } + + var address = connectedAp.Element(Scl + "Address"); + if (address is null) + { + address = new XElement(Scl + "Address"); + connectedAp.Add(address); + } + address.RemoveNodes(); + + var association = canonical.Communication.Association; + AddP(address, "OSI-AP-Title", association.ApTitle); + AddP(address, "OSI-AE-Qualifier", association.AeQualifier!.Value.ToString(CultureInfo.InvariantCulture)); + AddP(address, "OSI-PSEL", association.PresentationSelector); + AddP(address, "OSI-SSEL", association.SessionSelector); + AddP(address, "IP", canonical.Communication.Host); + AddP(address, "OSI-TSEL", association.TransportSelector); + AddP(address, "IP-SUBNET", canonical.Communication.IpSubnet); + AddP(address, "IP-GATEWAY", canonical.Communication.IpGateway); + } + + public static void PreserveRuntimeServiceCapacity( + XDocument document, + LiveIedModelDiscoveryDocument discovery) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(discovery); + // ReportControl elements may be projected from concrete runtime instances to + // indexed logical controls, but Services/ConfReportControl@max describes the + // observed runtime capacity and therefore stays bound to the physical inventory. + var confReportControl = document.Descendants(Scl + "ConfReportControl").SingleOrDefault(); + if (confReportControl is not null && discovery.ReportControls.Count > 0) + { + confReportControl.SetAttributeValue( + "max", + discovery.ReportControls.Count.ToString(CultureInfo.InvariantCulture)); + } + } + + public static void ApplyCanonicalInstanceValues( + XDocument document, + LiveIedCanonicalModel canonical) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(canonical); + if (canonical.InstanceValues.Count == 0) + return; + + var grouped = canonical.InstanceValues + .Where(value => + !string.IsNullOrWhiteSpace(value.Domain) && + !string.IsNullOrWhiteSpace(value.LogicalNode) && + !string.IsNullOrWhiteSpace(value.DataObject) && + !string.IsNullOrWhiteSpace(value.AttributePath) && + value.Value is not null) + .GroupBy( + value => string.Concat( + value.Domain.Trim(), "\u001F", + value.LogicalNode.Trim(), "\u001F", + value.DataObject.Trim(), "\u001F", + value.AttributePath.Trim()), + StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.Ordinal); + + foreach (var group in grouped) + { + var candidates = group.ToArray(); + var formatted = candidates + .Select(candidate => TryFormatScalarValue(candidate.Value, out var text) + ? text + : null) + .Where(text => text is not null) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (formatted.Length == 0) + continue; + if (formatted.Length != 1) + { + throw new InvalidDataException( + $"Conflicting live instance values were observed for '{candidates[0].Reference}'."); + } + + var evidence = candidates[0]; + var logicalNodeModel = FindLogicalNodeModel(canonical.Discovery, evidence); + if (logicalNodeModel is null) + continue; + + var lDevice = FindExportedLogicalDevice(document, canonical, evidence.Domain); + if (lDevice is null) + continue; + + var logicalNode = FindExportedLogicalNode(lDevice, logicalNodeModel); + if (logicalNode is null || + !IsExportedAttributePath(document, logicalNode, evidence.DataObject, evidence.AttributePath)) + { + continue; + } + + var doi = GetOrAddChild(logicalNode, "DOI", evidence.DataObject); + var segments = evidence.AttributePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + continue; + + XElement parent = doi; + for (var index = 0; index < segments.Length - 1; index++) + parent = GetOrAddChild(parent, "SDI", segments[index]); + + var dai = GetOrAddChild(parent, "DAI", segments[^1]); + var existingValues = dai.Elements(Scl + "Val").ToArray(); + if (existingValues.Length > 1) + { + throw new InvalidDataException( + $"Generated SCL contains multiple Val elements for '{evidence.Reference}'."); + } + + if (existingValues.Length == 1) + { + if (!string.Equals(existingValues[0].Value, formatted[0], StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"Generated SCL already contains a different Val for '{evidence.Reference}'."); + } + continue; + } + + dai.Add(new XElement(Scl + "Val", formatted[0])); + } + } + + private static LiveIedLogicalNodeModel? FindLogicalNodeModel( + LiveIedModelDiscoveryDocument discovery, + LiveIedInstanceValueEvidence evidence) + => discovery.LogicalDevices + .Where(device => string.Equals( + device.MmsDomain?.Trim(), + evidence.Domain.Trim(), + StringComparison.OrdinalIgnoreCase)) + .SelectMany(device => device.LogicalNodes) + .FirstOrDefault(node => string.Equals( + node.Name?.Trim(), + evidence.LogicalNode.Trim(), + StringComparison.OrdinalIgnoreCase)); + + private static XElement? FindExportedLogicalDevice( + XDocument document, + LiveIedCanonicalModel canonical, + string domain) + { + var normalizedDomain = domain.Trim(); + var iedName = canonical.IedName.Trim(); + var stripped = normalizedDomain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) && + normalizedDomain.Length > iedName.Length + ? normalizedDomain[iedName.Length..] + : normalizedDomain; + + var candidates = document.Descendants(Scl + "LDevice") + .Where(element => + { + var inst = ((string?)element.Attribute("inst") ?? string.Empty).Trim(); + return string.Equals(inst, stripped, StringComparison.OrdinalIgnoreCase) || + string.Equals(inst, normalizedDomain, StringComparison.OrdinalIgnoreCase); + }) + .ToArray(); + return candidates.Length == 1 ? candidates[0] : null; + } + + private static XElement? FindExportedLogicalNode( + XElement lDevice, + LiveIedLogicalNodeModel model) + { + if (string.Equals(model.Name, "LLN0", StringComparison.OrdinalIgnoreCase)) + return lDevice.Elements(Scl + "LN0").SingleOrDefault(); + + return lDevice.Elements(Scl + "LN") + .SingleOrDefault(element => + string.Equals( + ((string?)element.Attribute("prefix") ?? string.Empty).Trim(), + model.Prefix?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + ((string?)element.Attribute("lnClass") ?? string.Empty).Trim(), + model.LnClass?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase) && + string.Equals( + ((string?)element.Attribute("inst") ?? string.Empty).Trim(), + model.LnInst?.Trim() ?? string.Empty, + StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsExportedAttributePath( + XDocument document, + XElement logicalNode, + string dataObjectName, + string attributePath) + { + var lnTypeId = ((string?)logicalNode.Attribute("lnType") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(lnTypeId)) + return false; + + var templates = document.Root?.Element(Scl + "DataTypeTemplates"); + if (templates is null) + return false; + + var lnType = templates.Elements(Scl + "LNodeType") + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("id") ?? string.Empty).Trim(), + lnTypeId, + StringComparison.Ordinal)); + var dataObject = lnType?.Elements(Scl + "DO") + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + dataObjectName.Trim(), + StringComparison.OrdinalIgnoreCase)); + var typeId = ((string?)dataObject?.Attribute("type") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(typeId)) + return false; + + XElement? currentType = FindTemplateById(templates, "DOType", typeId); + var segments = attributePath + .Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + for (var index = 0; index < segments.Length; index++) + { + if (currentType is null) + return false; + + var segment = segments[index]; + var definition = currentType.Elements() + .SingleOrDefault(element => + (element.Name == Scl + "DA" || + element.Name == Scl + "BDA" || + element.Name == Scl + "SDO") && + string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + segment, + StringComparison.OrdinalIgnoreCase)); + if (definition is null) + return false; + + var isLast = index == segments.Length - 1; + if (isLast) + return definition.Name == Scl + "DA" || definition.Name == Scl + "BDA"; + + var nestedTypeId = ((string?)definition.Attribute("type") ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(nestedTypeId)) + return false; + + currentType = definition.Name == Scl + "SDO" + ? FindTemplateById(templates, "DOType", nestedTypeId) + : FindTemplateById(templates, "DAType", nestedTypeId); + } + + return false; + } + + private static XElement? FindTemplateById( + XElement templates, + string localName, + string id) + => templates.Elements(Scl + localName) + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("id") ?? string.Empty).Trim(), + id, + StringComparison.Ordinal)); + + private static XElement GetOrAddChild( + XElement parent, + string localName, + string name) + { + var existing = parent.Elements(Scl + localName) + .SingleOrDefault(element => string.Equals( + ((string?)element.Attribute("name") ?? string.Empty).Trim(), + name.Trim(), + StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + return existing; + + var created = new XElement( + Scl + localName, + new XAttribute("name", name.Trim())); + parent.Add(created); + return created; + } + + private static bool TryFormatScalarValue( + MmsDataValue value, + out string text) + { + text = string.Empty; + switch (value.Kind) + { + case MmsDataKind.Boolean: + text = Convert.ToBoolean(value.Value, CultureInfo.InvariantCulture) + ? "true" + : "false"; + return true; + case MmsDataKind.Integer: + case MmsDataKind.Unsigned: + text = Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty; + return true; + case MmsDataKind.FloatingPoint: + text = value.Value switch + { + float single => single.ToString("R", CultureInfo.InvariantCulture), + double number => number.ToString("R", CultureInfo.InvariantCulture), + _ => string.Empty + }; + return text.Length > 0; + case MmsDataKind.VisibleString: + case MmsDataKind.MmsString: + text = Convert.ToString(value.Value, CultureInfo.InvariantCulture) ?? string.Empty; + return true; + case MmsDataKind.UtcTime: + if (value.Value is Iec61850UtcTime utc) + { + text = Iec61850UtcTimeFormatter.FormatFullPrecisionUtc(utc); + return true; + } + return false; + case MmsDataKind.OctetString: + text = Convert.ToHexString(value.RawValue.ToArray()); + return true; + default: + // BitString/Quality, BinaryTime, arrays, structures, and unknown tags + // are intentionally omitted until their SCL lexical form is proven. + return false; + } + } + + private static void ValidateRoundTripAssociation(XDocument document, LiveIedCanonicalModel canonical) + { + var profiles = SclMmsAssociationProfileReader.Read(document); + var remote = profiles.Find(canonical.IedName, canonical.AccessPointName) + ?? throw new InvalidDataException( + $"Generated SCL cannot round-trip its own ConnectedAP '{canonical.IedName}/{canonical.AccessPointName}'."); + + var plan = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, + MmsLocalAssociationProfile.SclInteroperabilityDefault); + if (!plan.IsSuccess) + { + throw new InvalidDataException( + "Generated SCL failed its own MMS association-plan validation: " + + string.Join(" | ", plan.Errors)); + } + } + + private static void AddP(XElement address, string type, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + address.Add(new XElement(Scl + "P", new XAttribute("type", type), value.Trim())); + } + + private static void DeleteIfExists(string path) + { + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + File.Delete(path); + } +} diff --git a/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs new file mode 100644 index 00000000..beae54bf --- /dev/null +++ b/src/AR.Iec61850/Scl/Export/LiveRcbLogicalGroupProjector.cs @@ -0,0 +1,236 @@ +using System.Globalization; +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Scl.Export; + +/// +/// Projects concrete runtime RCB instances discovered over MMS into logical SCL +/// ReportControl definitions without changing the authoritative live model itself. +/// A collapse is allowed only when numbered siblings are contiguous from 01 and their +/// static configuration is compatible. Otherwise every runtime instance remains a +/// separate non-indexed ReportControl. +/// +internal static class LiveRcbLogicalGroupProjector +{ + internal sealed class Projection + { + public required LiveIedReportControlModel Representative { get; init; } + public required string LogicalName { get; init; } + public required string ReportId { get; init; } + public required IReadOnlyList RuntimeInstances { get; init; } + public bool Indexed => RuntimeInstances.Count > 1; + public int MaxInstances => RuntimeInstances.Count; + } + + private sealed record Candidate( + LiveIedReportControlModel Control, + int OriginalIndex, + string BaseName, + int InstanceIndex); + + public static IReadOnlyList Project( + IReadOnlyList runtimeControls) + { + ArgumentNullException.ThrowIfNull(runtimeControls); + if (runtimeControls.Count == 0) + return Array.Empty(); + + var candidates = runtimeControls + .Select((control, index) => TryParseInstance(control, index)) + .Where(candidate => candidate is not null) + .Cast() + .GroupBy( + candidate => new GroupKey( + candidate.Control.Domain.Trim(), + candidate.Control.LogicalNode.Trim(), + candidate.Control.Buffered, + candidate.BaseName), + GroupKeyComparer.Instance) + .ToDictionary(group => group.Key, group => group.ToArray(), GroupKeyComparer.Instance); + + var consumed = new HashSet(ReferenceEqualityComparer.Instance); + var projections = new List<(int Order, Projection Projection)>(); + + foreach (var group in candidates.Values) + { + if (!TryBuildLogicalGroup(group, out var projection, out var firstIndex)) + continue; + + foreach (var runtime in projection.RuntimeInstances) + consumed.Add(runtime); + projections.Add((firstIndex, projection)); + } + + for (var index = 0; index < runtimeControls.Count; index++) + { + var control = runtimeControls[index]; + if (consumed.Contains(control)) + continue; + + projections.Add((index, new Projection + { + Representative = control, + LogicalName = control.Name.Trim(), + ReportId = control.ReportId.Trim(), + RuntimeInstances = new[] { control } + })); + } + + return projections + .OrderBy(item => item.Order) + .Select(item => item.Projection) + .ToArray(); + } + + private static bool TryBuildLogicalGroup( + IReadOnlyList source, + out Projection projection, + out int firstIndex) + { + projection = null!; + firstIndex = int.MaxValue; + if (source.Count < 2) + return false; + + var ordered = source + .OrderBy(candidate => candidate.InstanceIndex) + .ThenBy(candidate => candidate.OriginalIndex) + .ToArray(); + + if (ordered.Select(candidate => candidate.InstanceIndex).Distinct().Count() != ordered.Length) + return false; + + for (var index = 0; index < ordered.Length; index++) + { + if (ordered[index].InstanceIndex != index + 1) + return false; + } + + var representative = ordered[0].Control; + if (ordered.Skip(1).Any(candidate => !StaticConfigurationMatches(representative, candidate.Control))) + return false; + + if (!TryResolveLogicalReportId(ordered, out var logicalReportId)) + return false; + + firstIndex = ordered.Min(candidate => candidate.OriginalIndex); + projection = new Projection + { + Representative = representative, + LogicalName = ordered[0].BaseName, + ReportId = logicalReportId, + RuntimeInstances = ordered.Select(candidate => candidate.Control).ToArray() + }; + return true; + } + + private static bool StaticConfigurationMatches( + LiveIedReportControlModel left, + LiveIedReportControlModel right) + => left.Buffered == right.Buffered && + Same(left.Domain, right.Domain) && + Same(left.LogicalNode, right.LogicalNode) && + Same(left.DataSetReference, right.DataSetReference) && + SameNumericText(left.ConfRev, right.ConfRev) && + SameNumericText(left.BufferTimeMs, right.BufferTimeMs) && + SameNumericText(left.IntegrityPeriodMs, right.IntegrityPeriodMs) && + Same(left.TriggerOptions, right.TriggerOptions) && + Same(left.OptionalFields, right.OptionalFields); + + private static bool TryResolveLogicalReportId( + IReadOnlyList ordered, + out string reportId) + { + reportId = ordered[0].Control.ReportId.Trim(); + var initialReportId = reportId; + if (ordered.All(candidate => Same(candidate.Control.ReportId, initialReportId))) + return true; + + string? baseReportId = null; + foreach (var candidate in ordered) + { + var value = candidate.Control.ReportId.Trim(); + if (string.IsNullOrWhiteSpace(value) || !TrySplitTwoDigitSuffix(value, out var currentBase, out var instanceIndex)) + return false; + if (instanceIndex != candidate.InstanceIndex) + return false; + + baseReportId ??= currentBase; + if (!Same(baseReportId, currentBase)) + return false; + } + + reportId = baseReportId ?? string.Empty; + return true; + } + + private static Candidate? TryParseInstance(LiveIedReportControlModel control, int originalIndex) + { + if (control is null || !TrySplitTwoDigitSuffix(control.Name.Trim(), out var baseName, out var instanceIndex)) + return null; + if (instanceIndex <= 0 || string.IsNullOrWhiteSpace(baseName)) + return null; + return new Candidate(control, originalIndex, baseName, instanceIndex); + } + + private static bool TrySplitTwoDigitSuffix(string value, out string baseName, out int instanceIndex) + { + baseName = string.Empty; + instanceIndex = 0; + if (string.IsNullOrWhiteSpace(value) || value.Length < 3) + return false; + + var suffix = value.AsSpan(value.Length - 2, 2); + if (!char.IsDigit(suffix[0]) || !char.IsDigit(suffix[1]) || + !int.TryParse(suffix, NumberStyles.None, CultureInfo.InvariantCulture, out instanceIndex)) + { + return false; + } + + baseName = value[..^2]; + return !string.IsNullOrWhiteSpace(baseName); + } + + private static bool Same(string? left, string? right) + => string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool SameNumericText(string? left, string? right) + { + var leftText = left?.Trim() ?? string.Empty; + var rightText = right?.Trim() ?? string.Empty; + if (ulong.TryParse(leftText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leftNumber) && + ulong.TryParse(rightText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rightNumber)) + { + return leftNumber == rightNumber; + } + + return Same(leftText, rightText); + } + + private readonly record struct GroupKey( + string Domain, + string LogicalNode, + bool Buffered, + string BaseName); + + private sealed class GroupKeyComparer : IEqualityComparer + { + public static GroupKeyComparer Instance { get; } = new(); + + public bool Equals(GroupKey x, GroupKey y) + => x.Buffered == y.Buffered && + Same(x.Domain, y.Domain) && + Same(x.LogicalNode, y.LogicalNode) && + Same(x.BaseName, y.BaseName); + + public int GetHashCode(GroupKey obj) + { + var hash = new HashCode(); + hash.Add(obj.Buffered); + hash.Add(obj.Domain, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.LogicalNode, StringComparer.OrdinalIgnoreCase); + hash.Add(obj.BaseName, StringComparer.OrdinalIgnoreCase); + return hash.ToHashCode(); + } + } +} diff --git a/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs b/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs new file mode 100644 index 00000000..ed57fb08 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Control/AuthoritativeControlInventoryTests.cs @@ -0,0 +1,144 @@ +using AR.Iec61850.Control; +using AR.Iec61850.Mms; +using System.Threading.Channels; + +namespace AR.Iec61850.Tests.Control; + +public sealed class AuthoritativeControlInventoryTests +{ + [Fact] + public async Task OpenCore_WithAuthoritativeInventory_DoesNotBrowseDomainVariablesAgain() + { + var transport = new CountingTransport(); + var service = new Iec61850ControlService(); + var authority = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = new[] { "CSWI1$ST$Pos$stVal" } + }; + + await using var session = await service.OpenCoreAsync( + transport, + "LD0/CSWI1.Pos", + authority, + CancellationToken.None); + + Assert.Equal(0, transport.DomainDiscoveryCalls); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Equal("ST", session.Descriptor.StatusFunctionalConstraint); + Assert.Contains("domainInventory=authoritative-reuse", session.Descriptor.DiscoveryEvidence, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task OpenCore_WithoutAuthoritativeInventory_PreservesLegacyFallback() + { + var transport = new CountingTransport(); + var service = new Iec61850ControlService(); + + await using var session = await service.OpenCoreAsync( + transport, + "LD0/CSWI1.Pos", + CancellationToken.None); + + Assert.Equal(1, transport.DomainDiscoveryCalls); + Assert.Equal("LD0/CSWI1.Pos.stVal", session.Descriptor.StatusReference); + Assert.Contains("domainInventory=live-fallback", session.Descriptor.DiscoveryEvidence, StringComparison.OrdinalIgnoreCase); + } + + private sealed class CountingTransport : IIec61850ControlTransport + { + private readonly Channel _reports = Channel.CreateUnbounded(); + + public object AssociationIdentity { get; } = new(); + public bool IsAssociated => true; + public string LastRequestHex => string.Empty; + public string LastResponseHex => string.Empty; + public int DomainDiscoveryCalls { get; private set; } + + public Task ReadAsync(MmsObjectReference reference, CancellationToken cancellationToken) + { + if (reference.Item.EndsWith("$ctlModel", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(new MmsReadResult + { + IsSuccess = true, + Value = MmsDataValue.Integer(3) + }); + } + + return Task.FromResult(new MmsReadResult + { + IsSuccess = false, + Message = "optional value not configured" + }); + } + + public Task GetVariableSpecificationAsync( + MmsObjectReference reference, + CancellationToken cancellationToken) + { + var configured = reference.Item.EndsWith("$Oper", StringComparison.OrdinalIgnoreCase) || + reference.Item.EndsWith("$Cancel", StringComparison.OrdinalIgnoreCase); + return Task.FromResult(new MmsVariableAccessAttributesResult + { + IsSuccess = configured, + Reference = reference, + TypeSpecification = configured ? CommandSpecification() : null, + Message = configured ? "ok" : "not configured" + }); + } + + public Task>> DiscoverDomainVariablesAsync( + CancellationToken cancellationToken) + { + DomainDiscoveryCalls++; + IReadOnlyDictionary> names = + new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = new[] { "CSWI1$ST$Pos$stVal" } + }; + return Task.FromResult(names); + } + + public Task WriteControlAsync( + MmsObjectReference reference, + MmsDataValue value, + CancellationToken cancellationToken) + => Task.FromResult(new MmsWriteResult { IsSuccess = true }); + + public IAsyncDisposable SubscribeInformationReports(out ChannelReader reader, int capacity = 32) + { + reader = _reports.Reader; + return new NoopSubscription(); + } + + private static MmsTypeSpecificationNode CommandSpecification() + => new() + { + MmsType = "structure", + Children = new MmsTypeSpecificationNode[] + { + new() { Name = "ctlVal", MmsType = "bit-string", Size = 2 }, + new() { Name = "operTm", MmsType = "utc-time" }, + new() + { + Name = "origin", + MmsType = "structure", + Children = new MmsTypeSpecificationNode[] + { + new() { Name = "orCat", MmsType = "integer" }, + new() { Name = "orIdent", MmsType = "octet-string", Size = 64 } + } + }, + new() { Name = "ctlNum", MmsType = "unsigned" }, + new() { Name = "T", MmsType = "utc-time" }, + new() { Name = "Test", MmsType = "boolean" }, + new() { Name = "Check", MmsType = "bit-string", Size = 2 } + } + }; + + private sealed class NoopSubscription : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + } +} diff --git a/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs new file mode 100644 index 00000000..599752d4 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/AuthoritativeLiveIedSclExporterValidationTests.cs @@ -0,0 +1,279 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class AuthoritativeLiveIedSclExporterValidationTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void ValidateExportGraph_AcceptsResolvedDataSetWithValidFcda() + { + var document = Document( + new XElement(Scl + "DataSet", + new XAttribute("name", "Analog"), + new XElement(Scl + "FCDA", + new XAttribute("ldInst", "LD0"), + new XAttribute("lnClass", "MMXU"), + new XAttribute("lnInst", "1"), + new XAttribute("doName", "A"), + new XAttribute("daName", "phsA.cVal.mag.f"), + new XAttribute("fc", "MX"))), + new XElement(Scl + "ReportControl", + new XAttribute("name", "BRCB01"), + new XAttribute("datSet", "Analog"))); + + Validate(document); + } + + [Fact] + public void ValidateExportGraph_RejectsUnresolvedReportControlDataSet() + { + var document = Document( + new XElement(Scl + "ReportControl", + new XAttribute("name", "BRCB01"), + new XAttribute("datSet", "Missing"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("references DataSet 'Missing'", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_RejectsEmptyDataSetBoundToReportControl() + { + var document = Document( + new XElement(Scl + "DataSet", new XAttribute("name", "Digital")), + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"), + new XAttribute("datSet", "Digital"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("contains no valid FCDA members", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_RejectsFcdaMissingRequiredIdentity() + { + var document = Document( + new XElement(Scl + "DataSet", + new XAttribute("name", "Digital"), + new XElement(Scl + "FCDA", + new XAttribute("ldInst", "LD0"), + new XAttribute("lnClass", "GGIO"), + new XAttribute("doName", "Ind1"))), + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"), + new XAttribute("datSet", "Digital"))); + + var error = Assert.Throws(() => Validate(document)); + + Assert.Contains("without required 'fc' identity", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ValidateExportGraph_AllowsReportControlWithoutDataSetBinding() + { + var document = Document( + new XElement(Scl + "ReportControl", + new XAttribute("name", "URCB01"))); + + Validate(document); + } + + [Fact] + public void ApplyReportControlConfiguration_CollapsesCompatibleRuntimeSiblingsToIndexedLogicalControl() + { + var controls = new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02", dataSet: "IED_ALD0/LLN0$Analog") + }; + + var result = ApplyReportProjection(controls); + var exported = result.Descendants(Scl + "ReportControl").Single(); + + Assert.Equal("Buffer", (string?)exported.Attribute("name")); + Assert.Equal("true", (string?)exported.Attribute("indexed")); + Assert.Equal("RID_Buffer", (string?)exported.Attribute("rptID")); + Assert.Equal("2", (string?)exported.Element(Scl + "RptEnabled")?.Attribute("max")); + Assert.DoesNotContain(result.Descendants(Scl + "ReportControl"), element => + string.Equals((string?)element.Attribute("name"), "Buffer01", StringComparison.OrdinalIgnoreCase) || + string.Equals((string?)element.Attribute("name"), "Buffer02", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("1", (string?)result.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + } + + [Fact] + public void ApplyReportControlConfiguration_LeavesSingletonConcreteAndNonIndexed() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog") + }); + var exported = result.Descendants(Scl + "ReportControl").Single(); + + Assert.Equal("Buffer01", (string?)exported.Attribute("name")); + Assert.Equal("false", (string?)exported.Attribute("indexed")); + Assert.Equal("1", (string?)exported.Element(Scl + "RptEnabled")?.Attribute("max")); + } + + [Fact] + public void ApplyReportControlConfiguration_DoesNotCollapseNonContiguousInstances() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer03", buffered: true, reportId: "RID_Buffer03", dataSet: "IED_ALD0/LLN0$Analog") + }); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(2, exported.Length); + Assert.All(exported, element => Assert.Equal("false", (string?)element.Attribute("indexed"))); + Assert.All(exported, element => Assert.Equal("1", (string?)element.Element(Scl + "RptEnabled")?.Attribute("max"))); + } + + [Fact] + public void ApplyReportControlConfiguration_DoesNotCollapseStaticConfigurationConflict() + { + var result = ApplyReportProjection(new[] + { + RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02", dataSet: "IED_ALD0/LLN0$Digital") + }); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(2, exported.Length); + Assert.Contains(exported, element => string.Equals((string?)element.Attribute("name"), "Unbuffer01", StringComparison.Ordinal)); + Assert.Contains(exported, element => string.Equals((string?)element.Attribute("name"), "Unbuffer02", StringComparison.Ordinal)); + Assert.All(exported, element => Assert.Equal("false", (string?)element.Attribute("indexed"))); + } + + [Fact] + public void ApplyReportControlConfiguration_ProjectsThirtyFourRuntimeInstancesToThirtyTwoLogicalControls() + { + var controls = Enumerable.Range(1, 30) + .Select(index => RuntimeControl($"Standalone_{index}_X", buffered: false, reportId: $"RID_{index}_X", dataSet: string.Empty)) + .Concat(new[] + { + RuntimeControl("Buffer01", buffered: true, reportId: "RID_Buffer01", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Buffer02", buffered: true, reportId: "RID_Buffer02", dataSet: "IED_ALD0/LLN0$Analog"), + RuntimeControl("Unbuffer01", buffered: false, reportId: "RID_Unbuffer01", dataSet: "IED_ALD0/LLN0$Digital"), + RuntimeControl("Unbuffer02", buffered: false, reportId: "RID_Unbuffer02", dataSet: "IED_ALD0/LLN0$Digital") + }) + .ToArray(); + + var result = ApplyReportProjection(controls); + var exported = result.Descendants(Scl + "ReportControl").ToArray(); + + Assert.Equal(34, controls.Length); + Assert.Equal(32, exported.Length); + Assert.Equal("32", (string?)result.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + + var buffer = exported.Single(element => string.Equals((string?)element.Attribute("name"), "Buffer", StringComparison.Ordinal)); + Assert.Equal("true", (string?)buffer.Attribute("indexed")); + Assert.Equal("2", (string?)buffer.Element(Scl + "RptEnabled")?.Attribute("max")); + + var unbuffer = exported.Single(element => string.Equals((string?)element.Attribute("name"), "Unbuffer", StringComparison.Ordinal)); + Assert.Equal("true", (string?)unbuffer.Attribute("indexed")); + Assert.Equal("2", (string?)unbuffer.Element(Scl + "RptEnabled")?.Attribute("max")); + } + + private static XDocument ApplyReportProjection(IReadOnlyList controls) + { + var source = ReportDocument(controls); + var model = new LiveIedModelDiscoveryDocument + { + IedName = "IED_A", + ReportControls = controls + }; + return AuthoritativeLiveIedSclExporter.ApplyReportControlConfiguration( + source, + model, + SclSchemaProfiles.Get(SclSchemaProfile.Edition2V31)); + } + + private static LiveIedReportControlModel RuntimeControl( + string name, + bool buffered, + string reportId, + string dataSet) + => new() + { + Reference = $"IED_ALD0/LLN0${(buffered ? "BR" : "RP")}${name}", + Domain = "IED_ALD0", + LogicalNode = "LLN0", + Name = name, + Buffered = buffered, + DataSetReference = dataSet, + ReportId = reportId, + ConfRev = "1", + TriggerOptions = "dchg,qchg,gi", + OptionalFields = "seqnum,timestamp,dataset,dataref", + BufferTimeMs = buffered ? "10" : "0", + IntegrityPeriodMs = "1000" + }; + + private static XDocument ReportDocument(IReadOnlyList controls) + => new( + new XElement(Scl + "SCL", + new XElement(Scl + "IED", + new XAttribute("name", "IED_A"), + new XElement(Scl + "Services", + new XElement(Scl + "ConfReportControl", + new XAttribute("max", controls.Count))), + new XElement(Scl + "AccessPoint", + new XAttribute("name", "AP1"), + new XElement(Scl + "Server", + new XElement(Scl + "LDevice", + new XAttribute("inst", "LD0"), + new XElement(Scl + "LN0", + new XAttribute("lnClass", "LLN0"), + controls.Select(control => + new XElement(Scl + "ReportControl", + new XAttribute("name", control.Name), + new XAttribute("buffered", control.Buffered ? "true" : "false"), + new XAttribute("confRev", "1"), + new XElement(Scl + "TrgOps"), + new XElement(Scl + "OptFields"), + new XElement(Scl + "RptEnabled", new XAttribute("max", "1"))))))))))); + + private static XDocument Document(params XElement[] logicalNodeChildren) + => new( + new XElement(Scl + "SCL", + new XElement(Scl + "IED", + new XAttribute("name", "IED_A"), + new XElement(Scl + "AccessPoint", + new XAttribute("name", "AP1"), + new XElement(Scl + "Server", + new XElement(Scl + "LDevice", + new XAttribute("inst", "LD0"), + new XElement(Scl + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("inst", string.Empty), + logicalNodeChildren))))))); + + private static void Validate(XDocument document) + { + var method = typeof(AuthoritativeLiveIedSclExporter).GetMethod( + "ValidateExportGraph", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException( + typeof(AuthoritativeLiveIedSclExporter).FullName, + "ValidateExportGraph"); + + try + { + method.Invoke(null, [document]); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } +} diff --git a/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs new file mode 100644 index 00000000..377c8d90 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Engineering/CanonicalLiveIedSclExporterTests.cs @@ -0,0 +1,301 @@ +using System.Xml.Linq; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using AR.Iec61850.Scl; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Engineering; + +public sealed class CanonicalLiveIedSclExporterTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void WriteFiles_RoundTripsCanonicalAssociationWithoutExporterDefaults(SclSchemaProfile schema) + { + var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-scl-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, schema == SclSchemaProfile.Edition2V31 ? "ied.iid" : "ied.icd"); + try + { + var canonical = CreateCanonical(); + CanonicalLiveIedSclExporter.WriteFiles(canonical, path, schema); + + var document = XDocument.Load(path); + var address = document.Descendants(Scl + "ConnectedAP").Single().Element(Scl + "Address")!; + var values = address.Elements(Scl + "P") + .ToDictionary(e => (string)e.Attribute("type")!, e => e.Value, StringComparer.OrdinalIgnoreCase); + + Assert.Equal("10.20.30.40", values["IP"]); + Assert.Equal("1,1,1,999,1", values["OSI-AP-Title"]); + Assert.Equal("12", values["OSI-AE-Qualifier"]); + Assert.Equal("00000001", values["OSI-PSEL"]); + Assert.Equal("0001", values["OSI-SSEL"]); + Assert.Equal("0001", values["OSI-TSEL"]); + Assert.False(values.ContainsKey("IP-SUBNET")); + Assert.False(values.ContainsKey("IP-GATEWAY")); + + var profiles = SclMmsAssociationProfileReader.Read(document); + var remote = Assert.Single(profiles.AccessPoints); + var plan = SclAssistedMmsAssociationPlanBuilder.BuildExact( + remote, + MmsLocalAssociationProfile.SclInteroperabilityDefault); + Assert.True(plan.IsSuccess, string.Join(" | ", plan.Errors)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ValidateCanonicalCommunication_RejectsMissingRemoteApTitle() + { + var canonical = CreateCanonical(); + canonical = new LiveIedCanonicalModel + { + Discovery = canonical.Discovery, + Communication = new LiveIedCommunicationEvidence + { + Host = canonical.Communication.Host, + AccessPointName = canonical.Communication.AccessPointName, + Association = new SclIsoAssociationAddress + { + AeQualifier = 12, + AeQualifierText = "12", + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; + + var ex = Assert.Throws(() => + CanonicalLiveIedSclExporter.ValidateCanonicalCommunication(canonical)); + Assert.Contains("OSI-AP-Title", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void PreserveRuntimeServiceCapacity_KeepsPhysicalCountIndependentOfLogicalProjection() + { + var document = XDocument.Parse( + ""); + var discovery = new LiveIedModelDiscoveryDocument + { + ReportControls = Enumerable.Range(1, 34) + .Select(i => new LiveIedReportControlModel { Name = $"R{i:00}" }) + .ToArray() + }; + + CanonicalLiveIedSclExporter.PreserveRuntimeServiceCapacity(document, discovery); + + Assert.Equal("34", (string?)document.Descendants(Scl + "ConfReportControl").Single().Attribute("max")); + } + + [Fact] + public void CanonicalBuilder_RetainsExactInitialFcLeafEvidence() + { + var discovery = CreateInstanceDiscovery(); + var communication = CreateCanonical().Communication; + var target = new InitialFcReadTarget + { + Domain = "IEDLD0", + LogicalNode = "GGIO1", + FunctionalConstraint = "ST", + MmsItemName = "GGIO1$ST", + DataObjects = + [ + new InitialFcReadDataObjectBinding + { + Name = "Ind1", + Reference = "IEDLD0/GGIO1.Ind1", + Leaves = + [ + new InitialFcReadLeafBinding + { + Reference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN" + } + ] + } + ] + }; + var projection = InitialFcValueProjector.Project( + target, + MmsDataValue.Structure( + [ + MmsDataValue.Structure([MmsDataValue.Boolean(true)]) + ])); + Assert.True(projection.IsExact, string.Join(" | ", projection.Errors)); + + var execution = new InitialFcReadExecutionResult + { + Status = InitialFcReadExecutionStatus.Completed, + Batches = + [ + new InitialFcReadBatchExecution + { + BatchIndex = 0, + Targets = [target], + Projections = [projection] + } + ] + }; + + var canonical = LiveIedCanonicalModelBuilder.Build( + discovery, + communication, + execution); + + var value = Assert.Single(canonical.InstanceValues); + Assert.Equal("IEDLD0", value.Domain); + Assert.Equal("GGIO1", value.LogicalNode); + Assert.Equal("Ind1", value.DataObject); + Assert.Equal("stVal", value.AttributePath); + Assert.Equal(MmsDataKind.Boolean, value.Value.Kind); + } + + [Theory] + [InlineData(SclSchemaProfile.Edition2V31)] + [InlineData(SclSchemaProfile.Edition1V16)] + public void WriteFiles_MaterializesExactInstanceValueAgainstExportedTypeTree( + SclSchemaProfile schema) + { + var root = Path.Combine(Path.GetTempPath(), "ariec-canonical-instance-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var path = Path.Combine(root, schema == SclSchemaProfile.Edition2V31 ? "ied.iid" : "ied.icd"); + try + { + var baseline = CreateCanonical(); + var canonical = new LiveIedCanonicalModel + { + Discovery = CreateInstanceDiscovery(), + Communication = baseline.Communication, + InstanceValues = + [ + new LiveIedInstanceValueEvidence + { + Domain = "IEDLD0", + LogicalNode = "GGIO1", + DataObject = "Ind1", + AttributePath = "stVal", + FunctionalConstraint = "ST", + SclBType = "BOOLEAN", + Value = MmsDataValue.Boolean(true) + } + ] + }; + + CanonicalLiveIedSclExporter.WriteFiles(canonical, path, schema); + + var document = XDocument.Load(path); + var lDevice = document.Descendants(Scl + "LDevice") + .Single(element => (string?)element.Attribute("inst") == "LD0"); + var logicalNode = lDevice.Elements(Scl + "LN") + .Single(element => + (string?)element.Attribute("lnClass") == "GGIO" && + (string?)element.Attribute("inst") == "1"); + var val = logicalNode.Elements(Scl + "DOI") + .Single(element => (string?)element.Attribute("name") == "Ind1") + .Elements(Scl + "DAI") + .Single(element => (string?)element.Attribute("name") == "stVal") + .Element(Scl + "Val"); + + Assert.NotNull(val); + Assert.Equal("true", val!.Value); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static LiveIedModelDiscoveryDocument CreateInstanceDiscovery() + => new() + { + Host = "10.20.30.40", + IedName = "IED", + AccessPointName = "AP1", + LogicalDevices = + [ + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "IEDLD0", + LogicalNodes = + [ + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + ProposedLnTypeId = "LN_GGIO_1", + DataObjects = + [ + new LiveIedDataObjectModel + { + Reference = "IEDLD0/GGIO1.Ind1", + Name = "Ind1", + ProposedDoTypeId = "DO_SPS_Ind1", + InferredCdc = "SPS", + CdcConfidence = 0.99, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Attributes = + [ + new LiveIedDataAttributeModel + { + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal", + Source = "GetNameList", + SclBType = "BOOLEAN", + MmsType = "Boolean", + TypeDiscoveryStatus = "Exact", + TypeSource = "GetVariableAccessAttributes", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + } + ] + } + ] + } + ] + } + ] + }; + + private static LiveIedCanonicalModel CreateCanonical() + => new() + { + Discovery = new LiveIedModelDiscoveryDocument + { + Host = "10.20.30.40", + IedName = "IED", + AccessPointName = "AP1" + }, + Communication = new LiveIedCommunicationEvidence + { + Source = "AcceptedAssociationProfile", + AssociationProfileName = "BalancedApTitle", + Host = "10.20.30.40", + Port = 102, + AccessPointName = "AP1", + Association = new SclIsoAssociationAddress + { + ApTitle = "1,1,1,999,1", + AeQualifierText = "12", + AeQualifier = 12, + PresentationSelector = "00000001", + SessionSelector = "0001", + TransportSelector = "0001" + } + } + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs b/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs new file mode 100644 index 00000000..f4bd65ea --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/LiveIedTypeHierarchyPointMaterializerTests.cs @@ -0,0 +1,142 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class LiveIedTypeHierarchyPointMaterializerTests +{ + [Fact] + public void Build_MaterializesMissingNamedLnTreeLeavesWithoutReplacingObservedPoints() + { + var observed = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "CSWI1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos.stVal", + MmsItemName = "CSWI1$ST$Pos$stVal", + Source = "LiveMmsGetNameList", + Confidence = 100 + }; + var directory = new MmsIedModelDirectory([observed]); + var result = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "CSWI1", string.Empty), + TypeSpecification = Structure( + "", + Structure( + "ST", + Structure( + "Pos", + Leaf("stVal", "bit-string", "Dbpos"), + Leaf("q", "bit-string", "Quality"), + Leaf("t", "utc-time", "Timestamp"), + Leaf("stSeld", "boolean", "BOOLEAN"))), + Structure( + "CO", + Structure( + "Pos", + Leaf("SBO", "visible-string", "ObjRef"), + Structure( + "Oper", + Leaf("ctlVal", "bit-string", "Dbpos"), + Leaf("Test", "boolean", "BOOLEAN")))), + Structure( + "CF", + Structure( + "Pos", + Leaf("ctlModel", "integer", "Enum"), + Leaf("sboTimeout", "unsigned", "INT32U"), + Leaf("operTimeout", "unsigned", "INT32U"))), + Structure( + "ZZ", + Structure( + "Ignored", + Leaf("value", "integer", "INT32"))), + new MmsTypeSpecificationNode + { + Name = "MX", + MmsType = "structure", + SclBType = "Struct", + Children = + [ + new MmsTypeSpecificationNode + { + Name = "Harm", + MmsType = "array", + SclBType = "Struct", + Children = [Leaf("element", "floating-point", "FLOAT32")] + } + ] + }) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [result]); + + Assert.Equal(10, directory.PointCount); + Assert.True(directory.TryFindByMmsReference("LD0/CSWI1$CO$Pos$SBO", out var sbo)); + Assert.Equal("Pos.SBO", sbo.DataObjectPath); + Assert.Equal("GetVariableAccessAttributesLogicalNodeTree", sbo.Source); + Assert.True(index.TryResolve(sbo, out var sboType)); + Assert.Equal("ObjRef", sboType.TypeSpecification.SclBType); + + Assert.True(directory.TryFindByMmsReference("LD0/CSWI1$ST$Pos$stVal", out var retained)); + Assert.Equal("LiveMmsGetNameList", retained.Source); + + Assert.DoesNotContain(directory.Points, point => point.FunctionalConstraint == "ZZ"); + Assert.DoesNotContain(directory.Points, point => point.MmsItemName.Contains("$element", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Build_DoesNotMaterializeHierarchyForUnknownLogicalNode() + { + var directory = new MmsIedModelDirectory( + [ + new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "CSWI1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos.stVal", + MmsItemName = "CSWI1$ST$Pos$stVal" + } + ]); + var foreign = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "XCBR9", string.Empty), + TypeSpecification = Structure( + "", + Structure("ST", Structure("Pos", Leaf("q", "bit-string", "Quality")))) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [foreign]); + + Assert.Single(directory.Points); + Assert.False(directory.TryFindByMmsReference("LD0/XCBR9$ST$Pos$q", out _)); + Assert.Equal(0, index.ResolvedAttributeCount); + } + + private static MmsTypeSpecificationNode Structure( + string name, + params MmsTypeSpecificationNode[] children) + => new() + { + Name = name, + MmsType = "structure", + SclBType = "Struct", + Children = children + }; + + private static MmsTypeSpecificationNode Leaf( + string name, + string mmsType, + string sclBType) + => new() + { + Name = name, + MmsType = mmsType, + SclBType = sclBType + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs new file mode 100644 index 00000000..c1f38c75 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsDataSetCompletenessRegressionTests.cs @@ -0,0 +1,57 @@ +using AR.Iec61850.Asn1; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsDataSetCompletenessRegressionTests +{ + [Fact] + public void Decode_PreservesExactMemberOrderAndMultiplicity() + { + var first = BuildMember("LD0", "GGIO1$ST$Ind1$stVal"); + var second = BuildMember("LD0", "MMXU1$MX$A$phsA$cVal$mag$f"); + var repeatedFirst = BuildMember("LD0", "GGIO1$ST$Ind1$stVal"); + + var listOfVariable = BerWriter.EncodeTlv( + 0xA1, + first.Concat(second).Concat(repeatedFirst).ToArray()); + var service = BerWriter.EncodeTlv( + 0xAC, + BerWriter.EncodeTlv(0x80, [0x00]) + .Concat(listOfVariable) + .ToArray()); + var response = BerWriter.EncodeTlv( + 0xA1, + new byte[] { 0x02, 0x01, 0x01 } + .Concat(service) + .ToArray()); + + var result = MmsDataSetDirectoryResponseDecoder.Decode( + response, + expectedInvokeId: 1, + dataSetReference: "LD0/LLN0.Events"); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(3, result.Members.Count); + Assert.Equal( + [ + "LD0/GGIO1$ST$Ind1$stVal", + "LD0/MMXU1$MX$A$phsA$cVal$mag$f", + "LD0/GGIO1$ST$Ind1$stVal" + ], + result.Members.Select(member => member.MmsReference).ToArray()); + } + + private static byte[] BuildMember(string domain, string item) + { + var memberObjectName = BerWriter.EncodeTlv( + 0xA1, + BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(domain)) + .Concat(BerWriter.EncodeTlv(0x1A, BerWriter.EncodeAscii(item))) + .ToArray()); + + return BerWriter.EncodeTlv( + 0x30, + BerWriter.EncodeTlv(0xA0, memberObjectName)); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs new file mode 100644 index 00000000..00f071ac --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDataSetPipelinePolicyTests.cs @@ -0,0 +1,23 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDataSetPipelinePolicyTests +{ + [Theory] + [InlineData(0, 8, 0)] + [InlineData(1, 8, 1)] + [InlineData(2, 8, 2)] + [InlineData(8, 4, 4)] + [InlineData(8, 0, 1)] + [InlineData(32, 10, 10)] + public void ResolveWorkerCount_RespectsAssociationWindowAndAvailableWork( + int dataSetCount, + int maxConcurrency, + int expected) + { + Assert.Equal( + expected, + MmsSmartDataSetPipelinePolicy.ResolveWorkerCount(dataSetCount, maxConcurrency)); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs new file mode 100644 index 00000000..00a8157a --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryKpiTests.cs @@ -0,0 +1,104 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDiscoveryKpiTests +{ + [Fact] + public void EquivalentEvidence_DifferentCompletionOrder_ProducesSameSignature() + { + var first = new MmsSmartDiscoveryKpiRecorder(1); + using (var a = first.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + using (var b = first.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1")) + { + b.Complete(true); + a.Complete(true); + } + first.UpdateCompleteness(32, 119, 3465, 4925, 2, 2, 58, 32, 16, 16); + + var second = new MmsSmartDiscoveryKpiRecorder(2); + using (var b = second.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1")) + using (var a = second.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + { + a.Complete(true); + b.Complete(true); + } + second.UpdateCompleteness(32, 119, 3465, 4925, 2, 2, 58, 32, 16, 16); + + Assert.Equal(first.Snapshot().DeterministicSignature, second.Snapshot().DeterministicSignature); + } + + [Fact] + public void RepeatedSemanticRequest_IsCountedAsDuplicate() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + + using (var first = recorder.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + first.Complete(true); + using (var second = recorder.BeginRequest("structure", "GetNameList", "NamedVariable|LD0|")) + second.Complete(true); + + var snapshot = recorder.Snapshot(); + var phase = Assert.Single(snapshot.Phases); + + Assert.Equal(2, snapshot.TotalRequests); + Assert.Equal(2, snapshot.SuccessfulRequests); + Assert.Equal(1, snapshot.DuplicateRequests); + Assert.Equal(1, phase.DuplicateRequests); + } + + [Fact] + public void PeakOutstanding_TracksConcurrentRequestsWithoutChangingRequestCount() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + + using var first = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XCBR1"); + using var second = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/XSWI1"); + using var third = recorder.BeginRequest("type-enrichment", "GetVariableAccessAttributes", "LD0/MMXU1"); + + third.Complete(true); + first.Complete(true); + second.Complete(false); + + var snapshot = recorder.Snapshot(); + + Assert.Equal(3, snapshot.TotalRequests); + Assert.Equal(3, snapshot.PeakOutstandingRequests); + Assert.Equal(2, snapshot.SuccessfulRequests); + Assert.Equal(1, snapshot.FailedRequests); + Assert.Equal(0, snapshot.DuplicateRequests); + } + + [Fact] + public void ModelRefresh_ChangesCompletenessAndSignatureWithoutAddingRequests() + { + var recorder = new MmsSmartDiscoveryKpiRecorder(1); + recorder.UpdateCompleteness(32, 119, 3465, 3465, 2, 2, 58, 32, 16, 16); + var before = recorder.Snapshot(); + + recorder.UpdateModelCompleteness(32, 119, 4925); + var after = recorder.Snapshot(); + + Assert.Equal(0, before.TotalRequests); + Assert.Equal(0, after.TotalRequests); + Assert.Equal(4925, after.FcPointCount); + Assert.NotEqual(before.DeterministicSignature, after.DeterministicSignature); + } + + [Fact] + public void PartialWireAccounting_IsExplicitAndDeterministic() + { + var first = new MmsSmartDiscoveryKpiRecorder(1); + first.MarkAccountingPartial("Report-enrichment confirmed Reads are not individually observed"); + + var second = new MmsSmartDiscoveryKpiRecorder(2); + second.MarkAccountingPartial("Report-enrichment confirmed Reads are not individually observed"); + + var firstSnapshot = first.Snapshot(); + var secondSnapshot = second.Snapshot(); + + Assert.False(firstSnapshot.WireAccountingComplete); + Assert.Single(firstSnapshot.AccountingNotes); + Assert.Equal(firstSnapshot.DeterministicSignature, secondSnapshot.DeterministicSignature); + } +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs new file mode 100644 index 00000000..f69ed5f8 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartDiscoveryPolicyTests.cs @@ -0,0 +1,223 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartDiscoveryPolicyTests +{ + [Fact] + public void Defaults_KeepExpensiveEnrichmentOffCriticalPath() + { + var options = new MmsSmartDiscoveryOptions(); + + Assert.False(options.ProbeReportAttributes); + Assert.False(options.ReadDataSetDirectories); + Assert.Equal(8, options.MaxConcurrentChains); + Assert.Equal(4, options.UnknownPeerMaxConcurrentChains); + Assert.Equal(64, options.MaxNameListPages); + } + + [Fact] + public void ResolveWindow_RespectsNegotiatedLimitAndConservativeUnknownFallback() + { + Assert.Equal(8, MmsSmartDiscoveryPolicy.ResolveWindow(8, 4, 10)); + Assert.Equal(10, MmsSmartDiscoveryPolicy.ResolveWindow(16, 4, 10)); + Assert.Equal(4, MmsSmartDiscoveryPolicy.ResolveWindow(8, 4, null)); + Assert.Equal(1, MmsSmartDiscoveryPolicy.ResolveWindow(0, 0, null)); + } + + [Fact] + public void DomainPriority_ReordersOnlyAlreadyObservedDomains() + { + var selected = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + ["LD1", "EXTRA", "LD0"], + maxDomains: 256); + + var scheduled = MmsSmartDiscoveryPolicy.OrderDomainsForScheduling( + selected, + ["LD1", "MISSING", "LD1"]); + + Assert.Equal(["EXTRA", "LD0", "LD1"], selected); + Assert.Equal(["LD1", "EXTRA", "LD0"], scheduled); + Assert.Equal( + selected.OrderBy(x => x, StringComparer.Ordinal), + scheduled.OrderBy(x => x, StringComparer.Ordinal)); + } + + [Fact] + public void DomainSelection_CollapsesCaseOnlyCollisionBeforeDictionaryPublication() + { + var selected = MmsSmartDiscoveryPolicy.SelectPublishedDomains( + ["ld0", "LD0", "LD1", "LD1"], + maxDomains: 256); + + Assert.Equal(["LD0", "LD1"], selected); + } + + [Fact] + public void LogicalNodePlanner_UsesOneCandidatePerLogicalNodeNotPerLeaf() + { + var directory = BuildDirectory(); + + var roots = LiveIedVariableTypeProbePlanner.BuildLogicalNodeRootCandidates(directory); + + Assert.Equal(2, roots.Count); + Assert.Contains(roots, root => root.Domain == "LD0" && root.Item == "MMXU1"); + Assert.Contains(roots, root => root.Domain == "LD0" && root.Item == "XCBR1"); + } + + [Fact] + public void TypeBudget_SelectsOnlyLogicalNodesProvenByLiveDirectory() + { + var directory = BuildDirectory(); + var selected = MmsSmartTypeProbePolicy.SelectLiveLogicalNodeRoots( + directory, + [ + new MmsObjectReference("ld0", "xcbr1", string.Empty), + new MmsObjectReference("LD0", "MISSING1", string.Empty), + new MmsObjectReference("LD0", "MMXU1$MX$PhV", "MX"), + new MmsObjectReference("LD0", "MMXU1", string.Empty), + new MmsObjectReference("LD0", "MMXU1", string.Empty) + ]); + + Assert.Equal(2, selected.Length); + Assert.Equal("MMXU1", selected[0].Item); + Assert.Equal("XCBR1", selected[1].Item); + Assert.All(selected, reference => Assert.Equal("LD0", reference.Domain)); + } + + [Fact] + public void TypeBudget_DoesNotRepeatExactDataObjectRootAsLeafFallback() + { + var dataObjectPoint = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "XCBR1", + FunctionalConstraint = "ST", + DataObjectPath = "Pos", + MmsItemName = "XCBR1$ST$Pos" + }; + var descendantPoint = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "MMXU1", + FunctionalConstraint = "MX", + DataObjectPath = "A.phsA.cVal.mag.f", + MmsItemName = "MMXU1$MX$A$phsA$cVal$mag$f" + }; + var alreadyProbed = new[] + { + MmsSmartTypeProbePolicy.BuildDataObjectRoot(dataObjectPoint), + MmsSmartTypeProbePolicy.BuildDataObjectRoot(descendantPoint) + }; + + var fallback = MmsSmartTypeProbePolicy.BuildUnprobedExactFallbacks( + [dataObjectPoint, descendantPoint, descendantPoint], + alreadyProbed); + + var exact = Assert.Single(fallback); + Assert.Equal("LD0", exact.Domain); + Assert.Equal("MMXU1$MX$A$phsA$cVal$mag$f", exact.Item); + Assert.DoesNotContain(fallback, reference => reference.Item == "XCBR1$ST$Pos"); + } + + [Fact] + public void TypeCoverage_UsesParentHierarchyAndRejectsUnknownBranch() + { + var root = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "MMXU1", string.Empty), + TypeSpecification = Node( + "", + Node("MX", + Node("PhV", + Node("phsA", + Node("cVal", + Node("mag", + Node("f"))))))) + }; + + Assert.True(MmsSmartTypeProbePolicy.Covers( + root, + "MMXU1$MX$PhV$phsA$cVal$mag$f")); + Assert.False(MmsSmartTypeProbePolicy.Covers( + root, + "MMXU1$MX$PhV$phsA$q")); + Assert.False(MmsSmartTypeProbePolicy.Covers( + root, + "XCBR1$ST$Pos$stVal")); + } + + [Fact] + public void TypeHierarchyIndex_MapsLogicalNodeTreeWithoutCrossNodeLeakage() + { + var directory = BuildDirectory(); + var typeResult = new MmsVariableAccessAttributesResult + { + IsSuccess = true, + Reference = new MmsObjectReference("LD0", "XCBR1", string.Empty), + TypeSpecification = Node( + "", + Node("ST", + Node("Pos", + Node("stVal"), + Node("q")))) + }; + + var index = LiveIedVariableTypeHierarchyIndex.Build(directory, [typeResult]); + var xcbrPoint = directory.Points.Single(point => point.MmsItemName == "XCBR1$ST$Pos$stVal"); + var mmxuPoint = directory.Points.Single(point => point.MmsItemName == "MMXU1$MX$PhV$phsA$cVal$mag$f"); + + Assert.True(index.TryResolve(xcbrPoint, out _)); + Assert.False(index.TryResolve(mmxuPoint, out _)); + } + + [Fact] + public void DataObjectFallbackRoot_StopsAtLnFcDo() + { + var point = new MmsFcResolvedPoint + { + Domain = "LD0", + LogicalNode = "MMXU1", + FunctionalConstraint = "MX", + DataObjectPath = "PhV.phsA.cVal.mag.f", + MmsItemName = "MMXU1$MX$PhV$phsA$cVal$mag$f" + }; + + var root = MmsSmartTypeProbePolicy.BuildDataObjectRoot(point); + + Assert.Equal("LD0", root.Domain); + Assert.Equal("MMXU1$MX$PhV", root.Item); + Assert.Equal("MX", root.FunctionalConstraint); + } + + private static MmsIedModelDirectory BuildDirectory() + { + var snapshot = new MmsDiscoverySnapshot + { + DomainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = + [ + "XCBR1$ST$Pos$stVal", + "XCBR1$ST$Pos$q", + "MMXU1$MX$PhV$phsA$cVal$mag$f", + "MMXU1$MX$A$phsA$cVal$mag$f" + ] + } + }; + + return MmsIedModelDirectoryBuilder.Build(snapshot); + } + + private static MmsTypeSpecificationNode Node( + string name, + params MmsTypeSpecificationNode[] children) + => new() + { + Name = name, + MmsType = children.Length == 0 ? "floating-point" : "structure", + Children = children + }; +} diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs new file mode 100644 index 00000000..88a870d3 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSmartInitialFcReadPolicyTests.cs @@ -0,0 +1,46 @@ +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSmartInitialFcReadPolicyTests +{ + [Fact] + public void Defaults_UseSmallBoundedReadWindow() + { + var options = new MmsSmartInitialFcReadOptions(); + + Assert.Equal(4, options.MaxOutstandingBatches); + Assert.Equal(2, options.UnknownPeerMaxOutstandingBatches); + Assert.Equal(TimeSpan.Zero, options.PerBatchTimeout); + } + + [Fact] + public void FcRootPlanner_RemainsBoundedAndDoesNotExpandToLeafReads() + { + var directory = MmsIedModelDirectoryBuilder.Build(new MmsDiscoverySnapshot + { + DomainVariables = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["LD0"] = + [ + "XCBR1$ST$Pos$stVal", + "XCBR1$ST$Pos$q", + "XCBR1$CF$Pos$ctlModel", + "MMXU1$MX$PhV$phsA$cVal$mag$f", + "MMXU1$MX$PhV$phsB$cVal$mag$f" + ] + } + }); + + var plan = InitialFcReadPlanner.FromLiveDirectory(directory); + var references = plan.Batches.SelectMany(batch => batch.References).ToArray(); + + Assert.Equal(3, references.Length); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "XCBR1$ST"); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "XCBR1$CF"); + Assert.Contains(references, reference => reference.Domain == "LD0" && reference.Item == "MMXU1$MX"); + Assert.DoesNotContain(references, reference => reference.Item.Contains("$Pos$", StringComparison.Ordinal)); + Assert.DoesNotContain(references, reference => reference.Item.Contains("$PhV$", StringComparison.Ordinal)); + Assert.All(plan.Batches, batch => Assert.InRange(batch.References.Count, 1, MmsReadBatchCodec.MaximumVariableReferencesPerRead)); + } +}