diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index ec15bc0fe..b715fbb89 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -54,270 +54,54 @@ jobs: $lock = Get-Content .\ARSAS\engines\ARIEC61850.lock.json -Raw | ConvertFrom-Json $convergence = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json $association = $convergence.postBaselineAcceptance.sclAssociationInteroperability + $accepted = $lock.sclAssociationInteroperability if ($lock.repository -ne 'masarray/ARIEC61850' -or - $lock.commit -notmatch '^[0-9a-f]{40} - - name: Checkout exact ARIEC61850 engine revision - shell: pwsh - run: | - git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 - git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT - git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT - $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "Engine SHA mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." - } - - - name: Verify canonical SCL interoperability contracts - shell: pwsh - run: | - $canonical = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\LiveIedCanonicalModel.cs -Raw - $exporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\CanonicalLiveIedSclExporter.cs -Raw - $association = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.AssociationEvidence.cs -Raw - $cotpClient = Get-Content .\ARIEC61850\src\AR.Iec61850\Osi\CotpClient.cs -Raw - $rcbProjector = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveRcbLogicalGroupProjector.cs -Raw - $smartDiscovery = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscovery.cs -Raw - $singleFlight = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartDiscoverySingleFlight.cs -Raw - $smartGva = Get-Content .\ARIEC61850\src\AR.Iec61850\Mms\MmsClientSession.SmartVariableAccessAttributes.cs -Raw - $referenceParts = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850ReferenceParts.cs -Raw - $liveExporter = Get-Content .\ARIEC61850\src\AR.Iec61850\Scl\Export\LiveIedSclExporter.cs -Raw - $standardRegistry = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\Iec61850StandardModelRegistry.cs -Raw - $cdcInference = Get-Content .\ARIEC61850\src\AR.Iec61850\Discovery\CdcInferenceEngine.cs -Raw - $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.CanonicalModel.cs -Raw - $capture = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryCapture.cs -Raw - $convergence = Get-Content .\ARSAS\evidence\interoperability-reference-target.json -Raw | ConvertFrom-Json - $buildTargets = Get-Content .\ARSAS\Directory.Build.targets -Raw - $lifecycle = Get-Content .\ARSAS\Services\NativeIec61850Client.SmartDiscoveryLifecycle.cs -Raw - $sclClient = Get-Content .\ARSAS\Services\NativeIec61850Client.SclAssisted.cs -Raw - $sclPreparation = Get-Content .\ARSAS\Services\SclAssistedConnectionPreparation.cs -Raw - $save = Get-Content .\ARSAS\MainWindow.xaml.cs -Raw - $reloadValidator = Get-Content .\ARSAS\Services\CanonicalSclReloadValidator.cs -Raw - $regression = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalLiveSclExportRegressionTests.cs -Raw - $reloadTests = Get-Content .\ARSAS\tests\ARSAS.Tests\CanonicalSclReloadValidatorTests.cs -Raw - - foreach ($required in @( - 'class LiveIedCanonicalModel', - 'InstanceValues', - 'LiveIedCommunicationEvidence')) { - if ($canonical -notmatch [regex]::Escape($required)) { - throw "Canonical engine contract missing: $required" - } + $lock.commit -notmatch '^[0-9a-f]{40}$') { + throw 'Invalid engine lock repository or commit SHA.' } - - foreach ($required in @( - 'ValidateCanonicalCommunication', - 'PreserveRuntimeServiceCapacity', - 'ApplyCanonicalInstanceValues', - 'ValidateRoundTripAssociation')) { - if ($exporter -notmatch [regex]::Escape($required)) { - throw "Canonical SCL exporter contract missing: $required" - } - } - - if ($association -notmatch 'GetAcceptedCommunicationEvidence') { - throw 'Accepted association evidence API is missing.' - } - - if ($env:ARIEC61850_SOURCE_PR -in @('135', '143')) { - $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' - if (!(Test-Path $wireReader) -or - $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or - $association -notmatch '_cotp\.LastConnectParameters\?\.DestinationTsap' -or - $cotpClient -notmatch 'public CotpConnectParameters\? LastConnectParameters' -or - $cotpClient -notmatch 'LastConnectParameters = acceptedParameters' -or - $association -match 'ApTitle\s*=\s*"1,1,1,999,1"') { - throw 'PR #135 must derive canonical AP/AE/selectors from exact accepted ACSE and COTP session evidence, not duplicated profile constants.' - } - } - - if ($smartDiscovery -notmatch 'DiscoverSmartAsync' -or - $singleFlight -notmatch 'DiscoverSmartSingleFlightAsync' -or - $smartGva -notmatch 'GetVariableAccessAttributesSmartAsync' -or - $referenceParts -notmatch 'instanceStart' -or - $referenceParts -notmatch 'IsFourLetterLnClass' -or - $liveExporter -notmatch 'TryResolveStandardSubDataObjectCdc' -or - $liveExporter -notmatch 'DataObjectReferencePaths' -or - $liveExporter -notmatch 'IsEdition2ServiceTrackingCdc' -or - $standardRegistry -notmatch 'Key\("TCTR", "ARtg"\)' -or - $standardRegistry -notmatch 'Key\("TVTR", "VRtg"\)' -or - $standardRegistry -notmatch 'Key\("LTIM", "TmChgDT"\)' -or - $standardRegistry -notmatch 'Key\("LTRK", "BrcbTrk"\)' -or - $standardRegistry -notmatch 'Key\("XCBR", "EEName"\)' -or - $standardRegistry -notmatch 'Key\("LLN0", "MltLev"\)' -or - $cdcInference -notmatch '"CST".*"BTS".*"UTS".*"STS".*"CTS"' -or - $capture -match 'TryBuildSupplementalGetNameListSnapshotAsync' -or - $capture -match 'DiscoverDomainVariableTypeTreeNamesAsync' -or - $capture -match 'AddAdaptiveLogicalNodeSiblingProbeSignalsAsync' -or - $capture -match 'EnrichEngineeringUnitsAsync') { - throw 'external IEC 61850 reference convergence source contract regressed: smart structure-first discovery or semantic SCL authority is missing, or a forbidden legacy browse path returned.' - } - - if ([int]$convergence.physicalReference.externalReferenceCapture.associations -ne 1 -or - [int]$convergence.physicalReference.rejectedLegacyArsasCapture.associations -ne 2 -or - -not [bool]$convergence.discoveryAcceptance.exactlyOneAssociation -or - -not [bool]$convergence.discoveryAcceptance.supplementalLegacyAssociationForbidden -or - -not [bool]$convergence.sclAcceptance.reopenInArsasRequired -or - -not [bool]$convergence.sclAcceptance.physicalReconnectRequired) { - throw 'external IEC 61850 reference physical acceptance target was weakened.' - } - - if ($consumer -notmatch 'LiveIedCanonicalModelBuilder\.Build\(model, communication, initialRead\)' -or - $capture -match 'InitialFcReadPlanner\.FromSclModel' -or - $capture -match 'ExecuteInitialFcReadPlanSmartAsync' -or - $capture -notmatch 'initialFcRoots=deferred' -or - $buildTargets -notmatch 'VerifyPhysicalProvenSmartDiscoveryRoute' -or - $buildTargets -notmatch '\-VerifyOnly' -or - $buildTargets -match 'GITHUB_WORKFLOW' -or - $buildTargets -match 'SmartDiscoveryProductionPromoted' -or - $lifecycle -notmatch 'PublishCanonicalModel\(model, initialRead\)' -or - $save -notmatch 'CanonicalLiveIedSclExporter\.WriteFiles' -or - $save -notmatch 'profile:\s*"full-model"' -or - $save -notmatch 'CanonicalSclReloadValidator\.Validate' -or - $sclPreparation -notmatch 'MmsLocalAssociationProfile\.ExistingRuntimeDefault' -or - $sclClient -notmatch 'IsSafeTrustedSclInitialReadFc' -or - $sclClient -notmatch 'ExecuteInitialFcReadPlanSmartAsync' -or - $reloadValidator -notmatch 'workspaceService\.Open' -or - $reloadValidator -notmatch 'result\.Profile.*full-model' -or - $reloadValidator -notmatch 'changed the proven calling-side runtime identity' -or - $reloadValidator -notmatch 'reproduce accepted association profile' -or - $regression -notmatch 'SmartDiscovery_DefersEagerFcValueReadsFromStructuralScan' -or - $reloadTests -notmatch 'ExportedCanonicalScl_ReopensThroughArsasWorkspaceWithoutStructuralDrift' -or - $reloadTests -notmatch 'BuildDefaultAssociationPayload' -or - $reloadTests -notmatch 'GoldenRcbShape_RoundTripsThirtyFourRuntimeAsThirtyTwoLogicalWithPhysicalCapacity' -or - $rcbProjector -match 'SameNumericText\(left\.IntegrityPeriodMs, right\.IntegrityPeriodMs\)' -or - $rcbProjector -match 'Same\(left\.OptionalFields, right\.OptionalFields\)') { - throw 'ARSAS canonical discovery -> full-model SCL -> exact native reconnect -> safe initial value-read contract is incomplete.' - } - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v6 - with: - dotnet-version: 8.0.x - - - name: Restore ARSAS solution - run: dotnet restore .\ARSAS\ArIED61850Tester.sln - - - name: Build ARSAS Release - run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore - - - name: Verify R7 smart discovery route was compiled - shell: pwsh - run: | - $consumer = Get-Content .\ARSAS\Services\NativeIec61850Client.cs -Raw - if ($consumer -notmatch 'DiscoverSignalsSmartForCaptureAsync\(cancellationToken, progress\)') { - throw 'R7 build compiled without the smart discovery route; refusing legacy-path artifact.' - } - if ($consumer -notmatch 'if \(SmartDiscoveryCaptureModeEnabled\)') { - throw 'R7 build did not install the smart discovery gate at DiscoverSignalsAsync.' - } - Write-Host 'R7 smart discovery route is installed in the compiled source.' - - - name: Run ARSAS canonical-export regressions - run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=arsas-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults - - - name: Build engine and run engine tests - shell: pwsh - run: | - dotnet restore .\ARIEC61850\ARIEC61850.sln - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet build .\ARIEC61850\ARIEC61850.sln -c Release --no-restore - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - dotnet test .\ARIEC61850\ARIEC61850.sln -c Release --no-build --no-restore --logger "trx;LogFileName=ariec61850-r7-scl-tests.trx" --results-directory .\ARSAS\TestResults - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Publish R7 portable single EXE - shell: pwsh - run: | - .\ARSAS\scripts\publish-windows-portable.ps1 ` - -Version $env:ARSAS_VERSION ` - -Runtime win-x64 ` - -SingleFile $true ` - -SelfContained $true ` - -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" - - - name: Smoke test R7 portable executable - shell: pwsh - run: | - $exe = ".\ARSAS\dist\ARSAS-$env:ARSAS_VERSION-win-x64-portable.exe" - if (!(Test-Path $exe -PathType Leaf)) { throw "Portable EXE missing: $exe" } - - $env:DOTNET_BUNDLE_EXTRACT_BASE_DIR = Join-Path $env:RUNNER_TEMP 'ARSAS-r7-scl-bundle-cache' - $process = Start-Process -FilePath $exe -ArgumentList @('--portable-smoke-test') -PassThru - if (-not $process.WaitForExit(30000)) { - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue - throw 'Portable EXE smoke test timed out.' - } - if ($process.ExitCode -ne 0) { - throw "Portable EXE smoke test failed: $($process.ExitCode)" - } - - $convergencePath = ".\ARSAS\evidence\interoperability-reference-target.json" - $convergenceHash = (Get-FileHash $convergencePath -Algorithm SHA256).Hash.ToLowerInvariant() - $convergence = Get-Content $convergencePath -Raw | ConvertFrom-Json - - @( - 'ARSAS R7 SCL interoperability field-test build', - "ARSAS commit: $env:ARSAS_COMMIT", - "ARIEC61850 commit: $env:ARIEC61850_COMMIT", - "Engine source PR: $env:ARIEC61850_SOURCE_PR", - "Convergence contract SHA256: $convergenceHash", - "Convergence status: $($convergence.status)", - 'Active stack: ARIEC61850 #134 -> #135 -> ARSAS #324', - '', - 'Acceptance target:', - 'IED -> fast structural Smart Discovery -> full-model SCL Ed1/Ed2 -> ARSAS Workspace Reload -> exact native calling/called association -> bounded safe FC-root snapshot.', - 'CI invariant: full-model export excludes no discovered attributes; SCL rebuilds the accepted native handshake; golden RCB shape is 34 runtime -> 32 logical with ConfReportControl max=34.', - 'Physical gate: one MMS association; no supplemental legacy browse; no recursive per-leaf GVA storm; same-relay PCAP + Ed2 IID + Ed1 ICD + diagnostic required before promotion.', - '', - 'This artifact is NOT production-promotion authority and does NOT replace P0-5e/P0-5f physical discovery-budget evidence.' - ) | Set-Content .\ARSAS\dist\R7-SCL-INTEROP-BUILD.txt -Encoding utf8 - - - name: Upload R7 SCL interoperability build - uses: actions/upload-artifact@v7 - with: - name: ARSAS-r7-scl-interoperability-win-x64 - path: | - ARSAS\dist\ARSAS-*-win-x64-portable.exe - ARSAS\dist\R7-SCL-INTEROP-BUILD.txt - ARSAS\evidence\interoperability-reference-target.json - ARSAS\docs\INTEROPERABILITY_REFERENCE_CONTRACT.md - ARSAS\TestResults\*.trx - if-no-files-found: error - retention-days: 14 - -or - $lock.ref -ne 'main') { - throw 'Invalid ARIEC61850 integration lock.' - } - if ($convergence.status -ne 'physical-retest-passed-merge-ready' -or [int]$convergence.activeStack.enginePerformance.pullRequest -ne 134 -or [int]$convergence.activeStack.engineModelAndScl.pullRequest -ne 135 -or [int]$convergence.activeStack.consumerIntegration.pullRequest -ne 324 -or - $convergence.activeStack.engineModelAndScl.head -ne $lock.mergedMainCommit -or - $lock.previousStablePin.commit -ne $lock.mergedMainCommit -or - [int]$lock.previousStablePin.sourcePullRequest -ne 135 -or [bool]$convergence.promotion.productionPromoted -or - [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest -or - [bool]$convergence.promotion.physicalRetestRequired -or - -not [bool]$convergence.promotion.mergeAllowedAfterPhysicalRetest -or - -not [bool]$convergence.promotion.physicalRetestPassed) { - throw 'Historical R7/R10 convergence authority was weakened.' + -not [bool]$convergence.promotion.physicalRetestPassed -or + [bool]$convergence.promotion.mergeAllowedBeforePhysicalRetest) { + throw 'Immutable R7/R10 physical convergence evidence was weakened.' } - - if ($association.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or - $association.status -ne 'physically-accepted-engine-merged-consumer-pending-merge' -or - -not [bool]$association.preservesR10Baseline -or - $association.engine.testedHead -ne $lock.commit -or - [int]$association.engine.pullRequest -ne [int]$lock.sourcePullRequest -or - [int]$lock.sourcePullRequest -ne 143 -or - -not [bool]$association.physicalAcceptance.fullDiscoverySkipped -or + if ($accepted.contractId -ne 'SCL-ASSOC-PARTIAL-ADDR-PHYSICAL' -or + [int]$accepted.sourcePullRequest -ne 143 -or + $accepted.testedEngineCommit -ne $association.engine.testedHead -or + $accepted.mergedEngineCommit -ne $association.engine.mergedMain -or + -not [bool]$accepted.preservesR10DiscoveryModelBaseline -or + -not [bool]$accepted.fullDiscoveryFallbackForbidden -or + -not [bool]$accepted.cyclicMmsProcessPollingForbidden -or [int]$association.physicalAcceptance.failedReads -ne 0 -or - [int]$association.physicalAcceptance.projectionErrors -ne 0 -or - [int]$association.physicalAcceptance.cacheLoss -ne 0 -or - [int]$association.physicalAcceptance.unresolvedRuntimePoints -ne 0 -or -not [bool]$association.physicalAcceptance.actualInformationReportObserved) { - throw 'Accepted post-baseline SCL association authority is incomplete or inconsistent.' + throw 'Physical SCL-association/R10 provenance is not intact.' + } + + $trial = $lock.smartInteroperabilityCodeTrial + if ($null -ne $trial -and $lock.commit -eq $trial.exactCommit) { + if ([int]$lock.sourcePullRequest -ne 148 -or + [int]$trial.integrationPullRequest -ne 148 -or + [int]$trial.ciRun -ne 759 -or + $trial.status -ne 'code-verified-not-physical' -or + -not [bool]$trial.physicalPromotionRequired -or + -not [bool]$trial.fullDiscoveryFallbackForbidden -or + $lock.purpose -notmatch 'CODE-VERIFIED INTEROPERABILITY TRIAL ONLY') { + throw 'Smart interoperability trial is not strictly CodeVerified and promotion-blocked.' + } + Write-Host 'Running stacked interoperability checks on a CodeVerified-only engine; no physical/mainline promotion is implied.' + } + elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { + if ([int]$lock.sourcePullRequest -notin @(134, 135) -or + $lock.purpose -notmatch 'R7 physical SCL repair pin') { + throw 'Historical R7 engine pin authority mismatch.' + } + } + else { + throw 'Unknown engine pin; neither physical R7 nor exact CodeVerified trial.' } "ARIEC61850_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append @@ -387,7 +171,7 @@ jobs: throw 'Accepted association evidence API is missing.' } - if ($env:ARIEC61850_SOURCE_PR -eq '135') { + if ($env:ARIEC61850_SOURCE_PR -in @('135', '143')) { $wireReader = '.\ARIEC61850\src\AR.Iec61850\Acse\AcseAssociationRequestIdentityReader.cs' if (!(Test-Path $wireReader) -or $association -notmatch 'AcseAssociationRequestIdentityReader\.Read' -or diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 82325a77e..1a11bd224 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using AR.Iec61850.Discovery; using AR.Iec61850.Scl.Workspace; @@ -47,6 +48,9 @@ public sealed class Iec61850MonitorDevice : ObservableObject public BulkObservableCollection CommandSignals { get; } = new(); public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); + [JsonIgnore] + public StaticAcquisitionParitySnapshot StaticAcquisitionParity { get; set; } = new(); + public SclIedWorkspace? SclWorkspace { get => _sclWorkspace; diff --git a/Models/StaticAcquisitionParityModels.cs b/Models/StaticAcquisitionParityModels.cs new file mode 100644 index 000000000..b1ab99539 --- /dev/null +++ b/Models/StaticAcquisitionParityModels.cs @@ -0,0 +1,68 @@ +namespace ArIED61850Tester.Models; + +public enum StaticAcquisitionIngressKind +{ + Unknown, + LiveDiscovery, + OpenScl +} + +public enum StaticAcquisitionParityStatus +{ + NotAvailable, + AwaitingOtherIngress, + Equivalent, + Mismatch, + InsufficientEvidence +} + +/// +/// Source-neutral semantic evidence for one Static DataSet planning pass. +/// +/// Concrete runtime RCB instance names are retained for diagnostics but are deliberately +/// excluded from SemanticFingerprint. An indexed RCB slot may legitimately change from +/// Rpt01 to Rpt02 across associations while the configured DataSet/report semantics remain +/// equivalent. +/// +public sealed class StaticAcquisitionIngressEvidence +{ + public DateTimeOffset CapturedAtUtc { get; init; } = DateTimeOffset.UtcNow; + public StaticAcquisitionIngressKind Ingress { get; init; } + public string IedName { get; init; } = string.Empty; + public string SemanticFingerprint { get; init; } = string.Empty; + public int RequestedPointCount { get; init; } + public int PlannedReportCount { get; init; } + public int StaticBrcbSignalCount { get; init; } + public int StaticUrcbSignalCount { get; init; } + public int UncoveredSignalCount { get; init; } + public IReadOnlyList SemanticLines { get; init; } = Array.Empty(); + public IReadOnlyList RuntimeTargets { get; init; } = Array.Empty(); + public bool IsComparable { get; init; } + public string IncomparableReason { get; init; } = string.Empty; + + public string Summary => + $"{Ingress}: fingerprint={SemanticFingerprint}, requested={RequestedPointCount}, " + + $"plans={PlannedReportCount}, staticBRCB={StaticBrcbSignalCount}, " + + $"staticURCB={StaticUrcbSignalCount}, uncovered={UncoveredSignalCount}"; +} + +public sealed class StaticAcquisitionParitySnapshot +{ + public StaticAcquisitionIngressEvidence? Discovery { get; init; } + public StaticAcquisitionIngressEvidence? OpenScl { get; init; } + public StaticAcquisitionParityStatus Status { get; init; } = StaticAcquisitionParityStatus.NotAvailable; + public IReadOnlyList Differences { get; init; } = Array.Empty(); + + public string Summary => Status switch + { + StaticAcquisitionParityStatus.Equivalent => + $"MATCH • {Discovery?.SemanticFingerprint ?? OpenScl?.SemanticFingerprint ?? "unavailable"}", + StaticAcquisitionParityStatus.Mismatch => + $"MISMATCH • discovery={Discovery?.SemanticFingerprint ?? "missing"} • scl={OpenScl?.SemanticFingerprint ?? "missing"}", + StaticAcquisitionParityStatus.InsufficientEvidence => + $"INCOMPLETE EVIDENCE • {string.Join("; ", Differences.Take(2))}", + StaticAcquisitionParityStatus.AwaitingOtherIngress => + $"AWAITING PEER INGRESS • {(Discovery is not null ? "Discovery captured" : "Open SCL captured")}", + _ => "not captured" + }; +} diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index 29936f986..ae7c2ec19 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -42,7 +42,7 @@ public static string BuildEmergency( foreach (var device in devices) { builder.AppendLine( - $"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status}"); + $"{device.DeviceId} | {device.Name} | endpoint={device.EndpointText} | connected={device.IsConnected} | monitoring={device.IsMonitoring} | status={device.Status} | staticParity={device.StaticAcquisitionParity.Summary}"); } if (devices.Count == 0) builder.AppendLine("No IED card is present."); @@ -136,6 +136,7 @@ public static async Task BuildAsync( builder.AppendLine($"Status : {device.Status}"); builder.AppendLine($"Detail : {device.Detail}"); builder.AppendLine($"Acquisition : {device.AcquisitionMode}"); + AppendStaticIngressParity(builder, device.StaticAcquisitionParity); builder.AppendLine($"Saved model : {device.HasDiscoveryCache} ({device.SignalCount:N0} signal(s))"); builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}"); builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}"); @@ -219,6 +220,30 @@ public static async Task BuildAsync( } + private static void AppendStaticIngressParity( + StringBuilder builder, + StaticAcquisitionParitySnapshot parity) + { + builder.AppendLine($"Static parity : {parity.Summary}"); + + if (parity.Discovery is { } discovery) + { + builder.AppendLine($" Discovery : {discovery.Summary}"); + foreach (var target in discovery.RuntimeTargets.Take(8)) + builder.AppendLine($" runtime : {target}"); + } + + if (parity.OpenScl is { } openScl) + { + builder.AppendLine($" Open SCL : {openScl.Summary}"); + foreach (var target in openScl.RuntimeTargets.Take(8)) + builder.AppendLine($" runtime : {target}"); + } + + foreach (var difference in parity.Differences.Take(12)) + builder.AppendLine($" difference : {difference}"); + } + private static RouteAnalysis AnalyzeRoute(string targetText) { if (!IPAddress.TryParse(targetText, out var target) || target.AddressFamily != AddressFamily.InterNetwork) diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index b2e746c20..915808183 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -1050,6 +1050,15 @@ private async Task> BuildReportPlansForCurrentA session.HybridValidation.Reset(hybrid); if (session.StaticDataSetReportOnly) { + var parity = StaticAcquisitionParityTracker.Record(session.Device, hybrid); + Log( + parity.Status is StaticAcquisitionParityStatus.Mismatch or StaticAcquisitionParityStatus.InsufficientEvidence ? "WARN" : "INFO", + session.Device.Name, + $"Static ingress parity evidence: {parity.Summary}. Concrete live RCB slots are diagnostic-only and excluded from the semantic fingerprint."); + + foreach (var difference in parity.Differences.Take(6)) + Log("WARN", session.Device.Name, $"Static ingress parity difference: {difference}"); + Log("INFO", session.Device.Name, $"Static DataSet ARIEC authority={hybrid.Authority}; status={hybrid.Status}; requested={hybrid.RequestedPointCount}, catalog={hybrid.CatalogMappedPointCount}, staticBRCB={hybrid.StaticBrcbSignalCount}, staticURCB={hybrid.StaticUrcbSignalCount}, engineFallbackCandidates={hybrid.PollingFallbackSignalCount}, uncovered={hybrid.UncoveredSignalCount}. Engine fallback candidates are diagnostic only and are not scheduled as MMS process polling."); } diff --git a/Services/StaticAcquisitionParityTracker.cs b/Services/StaticAcquisitionParityTracker.cs new file mode 100644 index 000000000..dfe0f7bd3 --- /dev/null +++ b/Services/StaticAcquisitionParityTracker.cs @@ -0,0 +1,238 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Builds a stable semantic fingerprint for Static DataSet acquisition and compares +/// Discovery vs Open-SCL ingress without confusing concrete live RCB slot ownership with +/// configuration equivalence. +/// +internal static class StaticAcquisitionParityTracker +{ + public static StaticAcquisitionParitySnapshot Record( + Iec61850MonitorDevice device, + NativeHybridReportPlanningResult planning) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(planning); + + var ingress = ResolveIngress(device); + if (ingress == StaticAcquisitionIngressKind.Unknown) + return device.StaticAcquisitionParity; + + var evidence = BuildEvidence(device, planning, ingress); + var previous = device.StaticAcquisitionParity; + + var discovery = ingress == StaticAcquisitionIngressKind.LiveDiscovery + ? evidence + : previous.Discovery; + var openScl = ingress == StaticAcquisitionIngressKind.OpenScl + ? evidence + : previous.OpenScl; + + var updated = Compare(discovery, openScl); + device.StaticAcquisitionParity = updated; + return updated; + } + + internal static StaticAcquisitionIngressEvidence BuildEvidence( + Iec61850MonitorDevice device, + NativeHybridReportPlanningResult planning, + StaticAcquisitionIngressKind ingress) + { + var model = ingress == StaticAcquisitionIngressKind.OpenScl + ? device.SclWorkspace?.DesignModel + : device.LiveDiscoveryModel; + + var staticPlans = planning.ReportPlans + .Where(plan => + plan.IsEngineAuthoritative && + plan.EngineAcquisitionKind.StartsWith("Static", StringComparison.OrdinalIgnoreCase)) + .OrderBy(plan => NormalizeReference(plan.DataSetReference), StringComparer.Ordinal) + .ThenBy(plan => NormalizeKind(plan.EngineAcquisitionKind), StringComparer.Ordinal) + .ToArray(); + + // A matching hash must never establish parity when the engine did not provide + // a complete, authoritative Static DataSet plan and ordered membership model. + var evidenceIssues = new List(); + if (!planning.IsAuthoritative) + evidenceIssues.Add("planner was not authoritative"); + if (planning.RequestedPointCount <= 0) + evidenceIssues.Add("no selected runtime points"); + if (planning.UncoveredSignalCount > 0) + evidenceIssues.Add($"{planning.UncoveredSignalCount} uncovered runtime point(s)"); + if (staticPlans.Length == 0) + evidenceIssues.Add("no engine-authoritative static report plans"); + if (model is null) + evidenceIssues.Add("ingress model missing"); + + var semanticLines = new List + { + $"ied={NormalizeReference(device.Name)}|requested={planning.RequestedPointCount}|" + + $"staticBrcbSignals={planning.StaticBrcbSignalCount}|staticUrcbSignals={planning.StaticUrcbSignalCount}|" + + $"uncovered={planning.UncoveredSignalCount}|staticPlans={staticPlans.Length}" + }; + + var runtimeTargets = new List(); + + foreach (var plan in staticPlans) + { + var dataSetReference = NormalizeReference(plan.DataSetReference); + var kind = NormalizeKind(plan.EngineAcquisitionKind); + var dataSet = model?.DataSets.FirstOrDefault(candidate => + NormalizeReference(candidate.Reference).Equals(dataSetReference, StringComparison.Ordinal)); + + var members = dataSet?.Members + .OrderBy(member => member.Index) + .Select(member => + $"{member.Index}:{NormalizeReference(member.Reference)}@{NormalizeFc(member.FunctionalConstraint)}") + .ToArray() + ?? Array.Empty(); + + if (dataSet is null) + evidenceIssues.Add($"DataSet {dataSetReference} missing from {ingress} model"); + else if (members.Length == 0) + evidenceIssues.Add($"DataSet {dataSetReference} has no ordered member evidence"); + else if (dataSet.MemberCount > 0 && dataSet.MemberCount != members.Length) + evidenceIssues.Add($"DataSet {dataSetReference} has {members.Length}/{dataSet.MemberCount} member descriptors"); + + var selected = plan.Bindings + .Where(point => !string.IsNullOrWhiteSpace(point.IecReference)) + .Select(point => + $"{NormalizeReference(point.IecReference)}@{NormalizeFc(point.FunctionalConstraint)}") + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + + if (selected.Length == 0) + evidenceIssues.Add($"DataSet {dataSetReference} has no selected point bindings"); + + semanticLines.Add( + $"{kind}|dataset={dataSetReference}|members={members.Length}[{string.Join(",", members)}]|" + + $"selected={selected.Length}[{string.Join(",", selected)}]"); + + runtimeTargets.Add( + $"{kind}|dataset={dataSetReference}|rcb={NormalizeReference(plan.ReportControlReference)}|" + + $"bindings={plan.Bindings.Count}"); + } + + var canonicalText = string.Join("\n", semanticLines); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(canonicalText))) + .ToLowerInvariant(); + + return new StaticAcquisitionIngressEvidence + { + CapturedAtUtc = DateTimeOffset.UtcNow, + Ingress = ingress, + IedName = device.Name, + SemanticFingerprint = fingerprint, + RequestedPointCount = planning.RequestedPointCount, + PlannedReportCount = staticPlans.Length, + StaticBrcbSignalCount = planning.StaticBrcbSignalCount, + StaticUrcbSignalCount = planning.StaticUrcbSignalCount, + UncoveredSignalCount = planning.UncoveredSignalCount, + SemanticLines = semanticLines, + RuntimeTargets = runtimeTargets, + IsComparable = evidenceIssues.Count == 0, + IncomparableReason = string.Join("; ", evidenceIssues) + }; + } + + internal static StaticAcquisitionParitySnapshot Compare( + StaticAcquisitionIngressEvidence? discovery, + StaticAcquisitionIngressEvidence? openScl) + { + if (discovery is null && openScl is null) + return new StaticAcquisitionParitySnapshot(); + + if (discovery is null || openScl is null) + { + return new StaticAcquisitionParitySnapshot + { + Discovery = discovery, + OpenScl = openScl, + Status = StaticAcquisitionParityStatus.AwaitingOtherIngress + }; + } + + if (!discovery.IsComparable || !openScl.IsComparable) + { + var issues = new List(); + if (!discovery.IsComparable) + issues.Add($"Discovery: {discovery.IncomparableReason}"); + if (!openScl.IsComparable) + issues.Add($"Open SCL: {openScl.IncomparableReason}"); + return new StaticAcquisitionParitySnapshot + { + Discovery = discovery, + OpenScl = openScl, + Status = StaticAcquisitionParityStatus.InsufficientEvidence, + Differences = issues + }; + } + + if (discovery.SemanticFingerprint.Equals( + openScl.SemanticFingerprint, + StringComparison.OrdinalIgnoreCase)) + { + return new StaticAcquisitionParitySnapshot + { + Discovery = discovery, + OpenScl = openScl, + Status = StaticAcquisitionParityStatus.Equivalent + }; + } + + var discoveryLines = discovery.SemanticLines.ToHashSet(StringComparer.Ordinal); + var sclLines = openScl.SemanticLines.ToHashSet(StringComparer.Ordinal); + var differences = discoveryLines + .Except(sclLines, StringComparer.Ordinal) + .Select(line => $"Discovery only: {line}") + .Concat(sclLines + .Except(discoveryLines, StringComparer.Ordinal) + .Select(line => $"Open SCL only: {line}")) + .Take(12) + .ToArray(); + + return new StaticAcquisitionParitySnapshot + { + Discovery = discovery, + OpenScl = openScl, + Status = StaticAcquisitionParityStatus.Mismatch, + Differences = differences + }; + } + + private static StaticAcquisitionIngressKind ResolveIngress(Iec61850MonitorDevice device) + { + if (device.SclWorkspace?.DesignModel is not null) + return StaticAcquisitionIngressKind.OpenScl; + if (device.LiveDiscoveryModel is not null) + return StaticAcquisitionIngressKind.LiveDiscovery; + return StaticAcquisitionIngressKind.Unknown; + } + + private static string NormalizeReference(string? value) + => (value ?? string.Empty) + .Trim() + .Replace('$', '.') + .Replace("..", ".") + .ToLowerInvariant(); + + private static string NormalizeKind(string? value) + { + var kind = (value ?? string.Empty).Trim(); + if (kind.Contains("brcb", StringComparison.OrdinalIgnoreCase)) + return "static-brcb"; + if (kind.Contains("urcb", StringComparison.OrdinalIgnoreCase)) + return "static-urcb"; + return "static-report"; + } + + private static string NormalizeFc(string? value) + => (value ?? string.Empty).Trim().ToUpperInvariant(); +} diff --git a/tests/ARSAS.Tests/StaticAcquisitionIngressParityTests.cs b/tests/ARSAS.Tests/StaticAcquisitionIngressParityTests.cs new file mode 100644 index 000000000..b45efd60a --- /dev/null +++ b/tests/ARSAS.Tests/StaticAcquisitionIngressParityTests.cs @@ -0,0 +1,315 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticAcquisitionIngressParityTests +{ + [Fact] + public void ConcreteIndexedRcbSlot_DoesNotChangeSemanticFingerprint() + { + var discoveryDevice = Device( + "IED-A", + LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"), + (1, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"))); + var sclDevice = Device( + "IED-A", + LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"), + (1, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"))); + sclDevice.SclWorkspace = new SclIedWorkspace + { + IedName = "IED-A", + AccessPointName = "AP1", + DesignModel = sclDevice.LiveDiscoveryModel! + }; + + var discovery = StaticAcquisitionParityTracker.BuildEvidence( + discoveryDevice, + Planning("IED-A/LLN0.BR.Rpt_ind01"), + StaticAcquisitionIngressKind.LiveDiscovery); + var openScl = StaticAcquisitionParityTracker.BuildEvidence( + sclDevice, + Planning("IED-A/LLN0.BR.Rpt_ind02"), + StaticAcquisitionIngressKind.OpenScl); + + Assert.Equal(discovery.SemanticFingerprint, openScl.SemanticFingerprint); + Assert.NotEqual(discovery.RuntimeTargets.Single(), openScl.RuntimeTargets.Single()); + + var parity = StaticAcquisitionParityTracker.Compare(discovery, openScl); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, parity.Status); + Assert.Empty(parity.Differences); + } + + [Fact] + public void OrderedDataSetMemberDrift_IsReportedAsMismatch() + { + var discoveryDevice = Device( + "IED-A", + LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"), + (1, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"))); + var sclDevice = Device( + "IED-A", + LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"), + (1, "IED-A/XCBR1.Pos.stVal", "ST"))); + // A genuine order comparison requires both ingress models to be populated. + // Without this workspace the old regression "passed" due to missing SCL data. + sclDevice.SclWorkspace = new SclIedWorkspace + { + IedName = "IED-A", + AccessPointName = "AP1", + DesignModel = sclDevice.LiveDiscoveryModel! + }; + + var discovery = StaticAcquisitionParityTracker.BuildEvidence( + discoveryDevice, + Planning("IED-A/LLN0.BR.Rpt_ind01"), + StaticAcquisitionIngressKind.LiveDiscovery); + var openScl = StaticAcquisitionParityTracker.BuildEvidence( + sclDevice, + Planning("IED-A/LLN0.BR.Rpt_ind01"), + StaticAcquisitionIngressKind.OpenScl); + + var parity = StaticAcquisitionParityTracker.Compare(discovery, openScl); + + Assert.NotEqual(discovery.SemanticFingerprint, openScl.SemanticFingerprint); + Assert.Equal(StaticAcquisitionParityStatus.Mismatch, parity.Status); + Assert.Contains(parity.Differences, item => + item.StartsWith("Discovery only:", StringComparison.Ordinal)); + Assert.Contains(parity.Differences, item => + item.StartsWith("Open SCL only:", StringComparison.Ordinal)); + } + + [Fact] + public void Record_KeepsBothIngressesOnOneLogicalDevice() + { + var device = Device( + "IED-A", + LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"), + (1, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"))); + + var first = StaticAcquisitionParityTracker.Record(device, Planning("IED-A/LLN0.BR.Rpt_ind01")); + Assert.Equal(StaticAcquisitionParityStatus.AwaitingOtherIngress, first.Status); + Assert.NotNull(first.Discovery); + Assert.Null(first.OpenScl); + + device.SclWorkspace = new SclIedWorkspace + { + IedName = "IED-A", + AccessPointName = "AP1", + DesignModel = device.LiveDiscoveryModel! + }; + + var second = StaticAcquisitionParityTracker.Record(device, Planning("IED-A/LLN0.BR.Rpt_ind02")); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, second.Status); + Assert.NotNull(second.Discovery); + Assert.NotNull(second.OpenScl); + } + + [Fact] + public void TwoMissingStaticPlans_MustNotBeMistakenForEquivalentRuntimeReporting() + { + var device = Device("IED-A", LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"))); + var noPlan = new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + RequestedPointCount = 1, + UncoveredSignalCount = 1 + }; + var discovery = StaticAcquisitionParityTracker.BuildEvidence( + device, noPlan, StaticAcquisitionIngressKind.LiveDiscovery); + var scl = StaticAcquisitionParityTracker.BuildEvidence( + device, noPlan, StaticAcquisitionIngressKind.OpenScl); + + // Equal hashes for equally absent plans do not prove parity. + Assert.Equal(discovery.SemanticFingerprint, scl.SemanticFingerprint); + Assert.False(discovery.IsComparable); + Assert.False(scl.IsComparable); + var parity = StaticAcquisitionParityTracker.Compare(discovery, scl); + Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence, parity.Status); + Assert.Contains("INCOMPLETE EVIDENCE", parity.Summary, StringComparison.Ordinal); + Assert.NotEmpty(parity.Differences); + } + + [Fact] + public void MissingOrderedDataSetAuthority_MustNotProduceMatch() + { + var model = LiveModel("IED-A", "IED-A/LLN0.Unrelated", + (0, "IED-A/XCBR1.Pos.stVal", "ST")); + var device = Device("IED-A", model); + device.SclWorkspace = new SclIedWorkspace + { + IedName = "IED-A", + AccessPointName = "AP1", + DesignModel = model + }; + + var discovery = StaticAcquisitionParityTracker.BuildEvidence( + device, Planning("IED-A/LLN0.BR.Rpt_ind01"), + StaticAcquisitionIngressKind.LiveDiscovery); + var scl = StaticAcquisitionParityTracker.BuildEvidence( + device, Planning("IED-A/LLN0.BR.Rpt_ind02"), + StaticAcquisitionIngressKind.OpenScl); + + Assert.Equal(discovery.SemanticFingerprint, scl.SemanticFingerprint); + Assert.False(discovery.IsComparable); + Assert.False(scl.IsComparable); + Assert.Contains("missing", discovery.IncomparableReason, StringComparison.Ordinal); + Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence, + StaticAcquisitionParityTracker.Compare(discovery, scl).Status); + } + + [Fact] + public void IncompleteNewIngress_ReplacesOldProofRatherThanRetainingStaleMatch() + { + var device = Device("IED-A", LiveModel("IED-A", "IED-A/LLN0.Events", + (0, "IED-A/XCBR1.Pos.stVal", "ST"), + (1, "IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"))); + StaticAcquisitionParityTracker.Record(device, Planning("IED-A/LLN0.BR.Rpt_ind01")); + device.SclWorkspace = new SclIedWorkspace + { + IedName = "IED-A", + AccessPointName = "AP1", + DesignModel = device.LiveDiscoveryModel! + }; + var matched = StaticAcquisitionParityTracker.Record(device, Planning("IED-A/LLN0.BR.Rpt_ind02")); + Assert.Equal(StaticAcquisitionParityStatus.Equivalent, matched.Status); + + var failure = new NativeHybridReportPlanningResult + { + IsAuthoritative = false, + RequestedPointCount = 2, + UncoveredSignalCount = 2 + }; + var afterFailure = StaticAcquisitionParityTracker.Record(device, failure); + Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence, afterFailure.Status); + Assert.False(afterFailure.OpenScl!.IsComparable); + } + + [Fact] + public void P73_RuntimeAndDiagnosticsExposeParityWithoutUsingConcreteSlotAsIdentity() + { + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + var diagnostics = Read("Services/DiagnosticReportBuilder.cs"); + var tracker = Read("Services/StaticAcquisitionParityTracker.cs"); + + Assert.Contains("StaticAcquisitionParityTracker.Record", runtime, StringComparison.Ordinal); + Assert.Contains("Static ingress parity evidence", runtime, StringComparison.Ordinal); + Assert.Contains("AppendStaticIngressParity", diagnostics, StringComparison.Ordinal); + Assert.Contains("Concrete live RCB slots are diagnostic-only", runtime, StringComparison.Ordinal); + Assert.Contains("RuntimeTargets", tracker, StringComparison.Ordinal); + + // Slot identity belongs to runtime diagnostics; only semanticLines are hashed. + // The old guard included runtimeTargets.Add(...) and falsely rejected valid diagnostics. + var fingerprintRegion = tracker[ + tracker.IndexOf("var canonicalText", StringComparison.Ordinal).. + tracker.IndexOf("return new StaticAcquisitionIngressEvidence", StringComparison.Ordinal)]; + Assert.Contains("SHA256.HashData", fingerprintRegion, StringComparison.Ordinal); + Assert.Contains("Encoding.UTF8.GetBytes(canonicalText)", fingerprintRegion, StringComparison.Ordinal); + Assert.DoesNotContain("ReportControlReference", fingerprintRegion, StringComparison.Ordinal); + Assert.DoesNotContain("runtimeTargets", fingerprintRegion, StringComparison.Ordinal); + } + + private static Iec61850MonitorDevice Device( + string name, + LiveIedModelDiscoveryDocument model) + => new() + { + Name = name, + IpAddress = "192.0.2.10", + LiveDiscoveryModel = model + }; + + private static LiveIedModelDiscoveryDocument LiveModel( + string iedName, + string dataSetReference, + params (int Index, string Reference, string Fc)[] members) + => new() + { + IedName = iedName, + Source = "Test", + DataSets = + [ + new LiveIedDataSetModel + { + Reference = dataSetReference, + Domain = iedName, + LogicalNode = "LLN0", + Name = "Events", + MemberCount = members.Length, + Members = members + .Select(member => new LiveIedDataSetMemberModel + { + Index = member.Index, + Reference = member.Reference, + FunctionalConstraint = member.Fc + }) + .ToArray() + } + ] + }; + + private static NativeHybridReportPlanningResult Planning(string concreteRcb) + { + var point1 = new Iec61850MonitorPoint + { + DeviceId = "device-a", + IecReference = "IED-A/XCBR1.Pos.stVal", + FunctionalConstraint = "ST" + }; + var point2 = new Iec61850MonitorPoint + { + DeviceId = "device-a", + IecReference = "IED-A/MMXU1.A.phsA.cVal.mag.f", + FunctionalConstraint = "MX" + }; + + return new NativeHybridReportPlanningResult + { + IsAuthoritative = true, + Authority = "ARIEC61850 canonical static acquisition", + Status = "Ready", + RequestedPointCount = 2, + CatalogMappedPointCount = 2, + StaticBrcbSignalCount = 2, + UncoveredSignalCount = 0, + ReportPlans = + [ + new ReportControlPlan + { + PlanId = "static-events", + IsEngineAuthoritative = true, + EngineAcquisitionKind = "StaticBrcb", + Buffered = true, + DataSetReference = "IED-A/LLN0.Events", + ReportControlReference = concreteRcb, + Bindings = [point1, point2] + } + ] + }; + } + + private static string Read(string relativePath) + => File.ReadAllText(FindRepoFile(relativePath)).Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException(relativePath); + } +}