diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4b18a5..0ce1227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: public-content: - name: Validate licensing, provenance, and public wording + name: Validate licensing, provenance, wording, and engine ownership runs-on: ubuntu-24.04 steps: - name: Checkout @@ -26,26 +26,69 @@ jobs: continue-on-error: true run: python3 scripts/validate-public-neutrality.py - - name: Upload terminology validation evidence + - name: Validate ARIEC61850 engine ownership boundary + id: ownership + continue-on-error: true + run: python3 scripts/validate-engine-ownership.py + + - name: Upload public and ownership validation evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: arsvin-public-neutrality-report - path: artifacts/public-neutrality-report.txt + name: arsvin-public-and-engine-ownership-reports + path: | + artifacts/public-neutrality-report.txt + artifacts/engine-ownership-report.txt if-no-files-found: error retention-days: 14 - - name: Enforce terminology validation result - if: steps.neutrality.outcome == 'failure' + - name: Enforce public and ownership validation results + if: steps.neutrality.outcome == 'failure' || steps.ownership.outcome == 'failure' run: exit 1 build-test: - name: Build, test, and validate site + name: Build, test, and validate pinned engine integration needs: public-content runs-on: windows-2025 + defaults: + run: + working-directory: arsvin + env: + ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout + - name: Checkout ARSVIN into paired workspace uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: arsvin + + - name: Resolve pinned ARIEC61850 revision + id: engine_pin + shell: pwsh + run: | + $pin = .\scripts\resolve-engine-pin.ps1 -AsObject + "repository=$($pin.Repository)" | Add-Content $env:GITHUB_OUTPUT + "ref=$($pin.Ref)" | Add-Content $env:GITHUB_OUTPUT + "commit=$($pin.Commit)" | Add-Content $env:GITHUB_OUTPUT + + - name: Checkout pinned sibling ARIEC61850 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: ${{ steps.engine_pin.outputs.repository }} + ref: ${{ steps.engine_pin.outputs.commit }} + path: ARIEC61850 + + - name: Record and verify paired engine revision + shell: pwsh + run: | + $engineSha = (git -C "$env:ARIEC61850_ROOT" rev-parse HEAD).Trim().ToLowerInvariant() + $expected = '${{ steps.engine_pin.outputs.commit }}'.ToLowerInvariant() + if ($engineSha -ne $expected) { + throw "Pinned engine mismatch. Expected $expected, found $engineSha." + } + "ARIEC61850_REF=${{ steps.engine_pin.outputs.ref }}" | Out-File artifacts-engine-revision.txt -Encoding utf8 + "ARIEC61850_EXPECTED_SHA=$expected" | Add-Content artifacts-engine-revision.txt + "ARIEC61850_ACTUAL_SHA=$engineSha" | Add-Content artifacts-engine-revision.txt + Get-Content artifacts-engine-revision.txt - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -53,13 +96,16 @@ jobs: dotnet-version: 8.0.4xx cache: true cache-dependency-path: | - Directory.Packages.props - src/**/packages.lock.json - tests/**/packages.lock.json + arsvin/Directory.Packages.props + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json - name: Verify current licensing and public wording run: python scripts/verify-current-license.py + - name: Validate engine ownership boundary + run: python scripts/validate-engine-ownership.py + - name: Build public site and HTML documentation run: python scripts/build-public-site.py --output artifacts/public-site @@ -67,35 +113,64 @@ jobs: shell: pwsh run: .\scripts\validate-public-site.ps1 -SiteRoot artifacts/public-site - - name: Restore locked NuGet dependency graph - run: dotnet restore ARSVIN.sln --locked-mode + - name: Restore dependency graph + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null + dotnet restore ARSVIN.sln 2>&1 | + Tee-Object -FilePath artifacts/build-logs/restore.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Upload committed NuGet lock evidence + - name: Upload NuGet lock and paired engine revision evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: arsvin-nuget-lockfiles + name: arsvin-dependency-evidence path: | - src/**/packages.lock.json - tests/**/packages.lock.json + arsvin/engines/ARIEC61850.lock.json + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json + arsvin/artifacts-engine-revision.txt if-no-files-found: error retention-days: 7 - name: Build Publisher with warnings as errors - run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror + shell: pwsh + run: | + dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror 2>&1 | + Tee-Object -FilePath artifacts/build-logs/publisher-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build Subscriber with warnings as errors - run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror + shell: pwsh + run: | + dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror 2>&1 | + Tee-Object -FilePath artifacts/build-logs/subscriber-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload paired restore and build diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: arsvin-paired-build-logs + path: arsvin/artifacts/build-logs + if-no-files-found: warn + retention-days: 14 - - name: Test with whole-engine and protocol-core coverage gates + - name: Test against pinned ARIEC61850 with integration coverage gates shell: pwsh - run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore + run: | + .\scripts\test-with-coverage.ps1 ` + -MinimumWholeEngineCoveredLines 3000 ` + -MinimumProtocolCoreCoveredLines 2300 ` + -MinimumLineCoverage 72.5 ` + -NoRestore - name: Upload test and coverage evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: arsvin-test-evidence - path: artifacts/test-results + path: arsvin/artifacts/test-results if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c527244..5c2749b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,11 +14,42 @@ permissions: jobs: analyze: - name: Analyze C# solution + name: Analyze pinned paired C# solution runs-on: windows-2025 + defaults: + run: + working-directory: arsvin + env: + ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout + - name: Checkout ARSVIN into paired workspace uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: arsvin + + - name: Resolve pinned ARIEC61850 revision + id: engine_pin + shell: pwsh + run: | + $pin = .\scripts\resolve-engine-pin.ps1 -AsObject + "repository=$($pin.Repository)" | Add-Content $env:GITHUB_OUTPUT + "commit=$($pin.Commit)" | Add-Content $env:GITHUB_OUTPUT + + - name: Checkout pinned sibling ARIEC61850 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: ${{ steps.engine_pin.outputs.repository }} + ref: ${{ steps.engine_pin.outputs.commit }} + path: ARIEC61850 + + - name: Verify pinned engine commit + shell: pwsh + run: | + $actual = (git -C "$env:ARIEC61850_ROOT" rev-parse HEAD).Trim().ToLowerInvariant() + $expected = '${{ steps.engine_pin.outputs.commit }}'.ToLowerInvariant() + if ($actual -ne $expected) { + throw "Pinned engine mismatch. Expected $expected, found $actual." + } - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -26,9 +57,9 @@ jobs: dotnet-version: 8.0.4xx cache: true cache-dependency-path: | - Directory.Packages.props - src/**/packages.lock.json - tests/**/packages.lock.json + arsvin/Directory.Packages.props + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json - name: Initialize CodeQL uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 @@ -36,11 +67,11 @@ jobs: languages: csharp build-mode: manual - - name: Restore locked solution dependency graph - run: dotnet restore ARSVIN.sln --locked-mode + - name: Restore pinned paired solution dependency graph + run: dotnet restore ARSVIN.sln - - name: Build solution with warnings as errors + - name: Build pinned paired solution with warnings as errors run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 \ No newline at end of file + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/ARSVIN.sln b/ARSVIN.sln index 8142fe5..e3bc36f 100644 --- a/ARSVIN.sln +++ b/ARSVIN.sln @@ -2,8 +2,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN.Engine", "src\ARSVIN.Engine\ARSVIN.Engine.csproj", "{B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN", "src\ARSVIN\ARSVIN.csproj", "{C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN.Subscriber", "src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj", "{A9F2E511-7D62-4C74-99CF-0E59B8D51377}" @@ -16,10 +14,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Release|Any CPU.Build.0 = Release|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Directory.Build.props b/Directory.Build.props index d584239..d1dd7af 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,13 @@ true true true - true + + false 0.4.0 $(GITHUB_SHA) true @@ -17,5 +23,13 @@ Ari Sulistiono Ari Sulistiono Copyright © 2026 Ari Sulistiono + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\ARIEC61850')) + $(ARIEC61850_ROOT)\src\AR.Iec61850\AR.Iec61850.csproj + $(ARIEC61850_ROOT)\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..97a028e --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,10 @@ + + + + + + diff --git a/build.ps1 b/build.ps1 index 48352be..e5d1cb6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,3 +1,10 @@ +[CmdletBinding()] +param( + [string] $EngineCommit = $(if ($env:ARIEC61850_COMMIT) { $env:ARIEC61850_COMMIT } else { '' }), + [string] $EngineRef = $(if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { '' }), + [switch] $AllowEngineDrift +) + $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest @@ -6,14 +13,95 @@ $solution = Join-Path $root 'ARSVIN.sln' $appProject = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' $subscriberProject = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$pin = & (Join-Path $root 'scripts\resolve-engine-pin.ps1') -RepositoryRoot $root -AsObject +$resolvedEngineCommit = if ([string]::IsNullOrWhiteSpace($EngineCommit)) { $pin.Commit } else { $EngineCommit.Trim().ToLowerInvariant() } +$resolvedEngineRef = if ([string]::IsNullOrWhiteSpace($EngineRef)) { $pin.Ref } else { $EngineRef.Trim() } + +if ($resolvedEngineCommit -notmatch '^[0-9a-f]{40}$') { + throw "ARIEC61850 commit '$resolvedEngineCommit' is not a full 40-character SHA." +} + +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter(Mandatory)][string[]] $Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} function Invoke-DotNet { param([Parameter(Mandatory)][string[]] $Arguments) - & dotnet @Arguments - if ($LASTEXITCODE -ne 0) { - throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + Invoke-Checked -FilePath 'dotnet' -Arguments $Arguments +} + +if (-not (Test-Path $engineProject -PathType Leaf)) { + if ($env:CI -ne 'true') { + throw "ARIEC61850 sibling repository was not found at $engineRoot. Clone https://github.com/$($pin.Repository) beside this repository, checkout $resolvedEngineCommit, or set ARIEC61850_ROOT." } + + Write-Host "==> CI bootstrap: fetching pinned ARIEC61850 commit $resolvedEngineCommit into $engineRoot" + $engineParent = Split-Path -Parent $engineRoot + New-Item -ItemType Directory -Path $engineParent -Force | Out-Null + if (Test-Path $engineRoot) { + Remove-Item $engineRoot -Recurse -Force + } + New-Item -ItemType Directory -Path $engineRoot -Force | Out-Null + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'init') + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'remote', 'add', 'origin', $pin.RepositoryUrl) + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'fetch', '--depth', '1', 'origin', $resolvedEngineCommit) + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'checkout', '--detach', 'FETCH_HEAD') +} + +$engineShaOutput = & git -C $engineRoot rev-parse HEAD 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the paired ARIEC61850 commit.`n$($engineShaOutput -join [Environment]::NewLine)" +} +$engineSha = ($engineShaOutput -join '').Trim().ToLowerInvariant() + +if (-not $AllowEngineDrift -and $engineSha -ne $resolvedEngineCommit) { + throw "ARIEC61850 checkout mismatch. Expected $resolvedEngineCommit but found $engineSha. Run: git -C `"$engineRoot`" fetch origin $resolvedEngineCommit; git -C `"$engineRoot`" checkout --detach $resolvedEngineCommit" +} + +$env:ARIEC61850_ROOT = $engineRoot +$env:ARIEC61850_REF = $resolvedEngineRef +$env:ARIEC61850_COMMIT = $engineSha +if ($env:GITHUB_ENV) { + "ARIEC61850_ROOT=$engineRoot" | Add-Content $env:GITHUB_ENV + "ARIEC61850_REF=$resolvedEngineRef" | Add-Content $env:GITHUB_ENV + "ARIEC61850_COMMIT=$engineSha" | Add-Content $env:GITHUB_ENV +} + +$artifactRoot = Join-Path $root 'artifacts' +New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null +$revisionEvidence = [ordered]@{ + schemaVersion = 1 + repository = $pin.Repository + configuredRef = $resolvedEngineRef + expectedCommit = $resolvedEngineCommit + actualCommit = $engineSha + exactMatch = ($engineSha -eq $resolvedEngineCommit) + engineRoot = $engineRoot + applicationCommit = (& git -C $root rev-parse HEAD 2>$null | Select-Object -First 1) + generatedAt = [DateTimeOffset]::UtcNow.ToString('O') } +$revisionEvidence | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $artifactRoot 'engine-revision.json') -Encoding utf8 + +Write-Host "==> ARIEC61850 root: $engineRoot" +Write-Host "==> ARIEC61850 ref: $resolvedEngineRef" +Write-Host "==> ARIEC61850 expected commit: $resolvedEngineCommit" +Write-Host "==> ARIEC61850 actual commit: $engineSha" Write-Host '==> Verifying current license, provenance, and public wording' & python (Join-Path $root 'scripts\verify-current-license.py') @@ -27,8 +115,14 @@ if ($LASTEXITCODE -ne 0) { throw 'Public terminology neutrality validation failed.' } -Write-Host '==> Restoring locked solution dependency graph' -Invoke-DotNet -Arguments @('restore', $solution, '--locked-mode') +Write-Host '==> Validating ARIEC61850 engine ownership boundary' +& python (Join-Path $root 'scripts\validate-engine-ownership.py') +if ($LASTEXITCODE -ne 0) { + throw 'Engine ownership validation failed.' +} + +Write-Host '==> Restoring application and pinned sibling-engine dependency graph' +Invoke-DotNet -Arguments @('restore', $solution) Write-Host '==> Building ARSVIN Publisher' Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore', '-warnaserror') @@ -36,7 +130,7 @@ Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore' Write-Host '==> Building ArSubsv Subscriber' Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore', '-warnaserror') -Write-Host '==> Running tests' +Write-Host '==> Running integration regression tests against pinned ARIEC61850' Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') -Write-Host '==> Build completed successfully' \ No newline at end of file +Write-Host '==> Paired build completed successfully' diff --git a/docs/arsubsv-generic-explorer-roadmap.md b/docs/arsubsv-generic-explorer-roadmap.md new file mode 100644 index 0000000..50e947b --- /dev/null +++ b/docs/arsubsv-generic-explorer-roadmap.md @@ -0,0 +1,216 @@ +# ArSubsv generic Sampled Values explorer roadmap + +## Product decision + +ArSubsv is a generic IEC 61850 Sampled Values subscriber, inspector, measurement viewer, and evidence tool. + +The receive path must not contain manufacturer or device-family branches. Product identity may appear in imported evidence or reports, but it must never select a parser, dataset order, engineering scale, quality layout, or health result. + +The intended receive pipeline is: + +```text +Ethernet / VLAN / APPID + ↓ +generic IEC 61850-9-2 APDU and ASDU parser + ↓ +generic seqOfData structural inspection + ↓ +optional SCL-driven ordered dataset mapping + ↓ +explicit standard-layout or engineering-context evidence + ↓ +waveform, RMS, phasor, quality, and report +``` + +## Reference behavior + +A proven SV engineering workflow discovers one or more streams, shows waveforms, RMS values, phase angles, phasors, individual samples, recording/replay, and reports. Configurable IEC 61869-9 datasets are interpreted and checked through imported SCL rather than manufacturer heuristics. + +ArSubsv should reproduce that workflow while keeping every interpretation explainable. + +## Audit findings + +### Correct foundations already present + +- One generic Ethernet/APDU/ASDU parser is shared across live capture and PCAP replay. +- SCL can provide an ordered payload layout and configuration comparison. +- Raw protocol values, scaling provenance, timebase provenance, quality diagnostics, and evidence reports already exist. +- Selected-stream visualization is stable and bounded. +- CT/VT primary/secondary display context is explicit and does not alter raw bytes. + +### Incorrect or unsafe behavior to remove + +1. **Unbound payload auto-layout** + + The current Subscriber recognizes selected payload pair counts and assigns names such as Ia, Ib, Ic, In, Va, Vb, Vc, and Vn. A 64-byte payload can therefore be interpreted as a fixed 4I+4V layout without SCL. This is structural guessing and can mislabel configurable IEC 61869-9 datasets. + +2. **Synthetic IEC references** + + Generic traffic can receive generated references such as `TCTR1/AmpSv.instMag.i`. Those references look authoritative even though they were not read from SCL or the wire. + +3. **Unbound engineering scaling** + + Packet length and observed sampling rate were previously sufficient to activate provisional A/V scaling. This is now hardened: generic unbound traffic remains raw counts. + +4. **Unknown mapping treated as stream warning** + + A valid stream without SCL is currently classified as WARN because channel names or scaling are unresolved. Missing semantics are not a protocol failure. Protocol/stream integrity and interpretation completeness must be separate states. + +5. **Waveform model fixed to eight named channels** + + The current waveform container is optimized for Ia/Ib/Ic/In and Va/Vb/Vc/Vn. Configurable datasets require a generic selected-element trace model before semantic aliases are added. + +6. **Profile-oriented compact state** + + The UI emphasizes profile and confidence. The primary receive workflow should instead emphasize wire validity, mapping source, semantic completeness, and measurement provenance. + +7. **Global parse health can remain sticky** + + Lifetime parse-error count can keep the global state BAD after the current traffic has recovered. Lifetime counters should remain evidence, while current health should use a rolling window. + +## Implemented in P3A + +### Generic seqOfData inspection + +`SvGenericPayloadInspector` exposes every complete big-endian 32-bit word through: + +- byte offset, +- raw hexadecimal bytes, +- signed INT32 representation, +- unsigned UINT32 representation, +- IEEE-754 FLOAT32 representation, +- structural position in an eight-byte group when applicable. + +An eight-byte grouping shape is only structural evidence. The second word is not called quality until SCL or an explicit reviewed standard layout resolves it. + +Trailing bytes are preserved and reported instead of silently discarded. + +### Generic ASDU inspection + +`SvGenericAsduInspector` exposes: + +- `svID`, +- dataset reference, +- `smpCnt`, +- `confRev`, +- `smpSynch`, +- presence of `refrTm`, +- optional `smpRate` and `smpMod`, +- generic payload inspection. + +Dataset reference presence does not mean dataset semantics are already known; SCL binding is still required. + +### Scaling hardening + +Engineering A/V scaling now requires all of the following: + +1. SCL-derived current or voltage semantics, +2. fixed 4-current/4-voltage value-quality structure, +3. expected payload size, +4. declared or observed protection-rate evidence. + +Packet shape, rate, product name, MAC, APPID, `svID`, amplitude, and manufacturer identity cannot activate scaling on their own. + +### Generic Subscriber presentation + +When an SCL mapping is not bound, the Subscriber presentation layer now: + +- replaces synthetic channel labels with generic word labels and byte offsets, +- shows signed and unsigned representations of each 32-bit word, +- labels semantics as unresolved, +- suppresses semantic waveform, RMS, and phasor displays, +- renames the compact profile indicators to mapping and semantics. + +When SCL is bound, existing ordered SCL decoding and measurement views remain available. + +## Next implementation tranches + +### P3B — replace the runtime auto-layout path + +Replace the unbound runtime auto-layout decoder with `SvGenericPayloadInspector`, so synthetic channel references are not created internally at all. + +Without SCL, the Decoded table contract is: + +| Field | Meaning | +|---|---| +| Offset | byte position inside seqOfData | +| Word | generic word index | +| INT32 | signed big-endian representation | +| UINT32 | unsigned representation | +| FLOAT32 | alternate representation, clearly marked | +| Raw | exact bytes | +| Semantics | unresolved | + +No Ia/Va labels, no A/V units, no quality health decisions, and no phasor calculation are permitted in raw generic mode. + +### P3C — SCL-driven semantic mapping + +Resolve the ordered path: + +```text +SampledValueControl + → datSet + → DataSet FCDA order + → DO/DA/BType/CDC + → payload element decoder +``` + +Each displayed field must carry a mapping source: + +- wire observed, +- SCL derived, +- explicit standard-layout selection, +- manual engineering context, +- device-validated evidence. + +Unknown, absent, and conflicting states must remain distinct. + +### P3D — generic selected-element waveform + +Introduce a generic numeric trace model independent of Ia/Ib/Ic/Va/Vb/Vc. + +- Any mapped numeric element can be selected for waveform display. +- Raw mode may plot an explicitly selected word as counts. +- RMS is available when a complete time window exists. +- Phase angle and phasor are available only when nominal frequency/timebase and signal semantics are resolved. +- Standard phase aliases are conveniences derived from SCL, not parser assumptions. + +### P3E — explainable health layers + +Expose four independent states: + +```text +PROTOCOL GOOD / BAD +STREAM GOOD / WARN / BAD +CONFIGURATION MATCH / WARN / BAD / NOT LOADED +MEASUREMENT RESOLVED / PARTIAL / RAW +``` + +The global headline should be driven by current protocol and stream integrity. Missing SCL or unresolved engineering scale must not turn a valid stream BAD. + +### P3F — evidence and workflow parity + +- record/replay and COMTRADE export, +- detailed individual-sample view, +- zero-crossing and timing statistics, +- packet-interval histogram, +- optional-field verification, +- SCL orphan discovery, +- printable and JSON evidence reports, +- selected-stream stability under live refresh, +- capture/application drop attribution without claiming kernel drops that were not observed. + +## Real-device evidence policy + +PCAP and SCL from any conforming or installed-base device are regression fixtures for the generic engine. They are not permission to add manufacturer-specific decoding branches. + +A real-device fixture should prove: + +- generic parser compatibility, +- SCL mapping correctness, +- sample-counter and timing behavior, +- quality interpretation, +- engineering-value agreement with known injection, +- report reproducibility. + +Any device-specific exception must be documented as an implementation defect or explicit compatibility observation, isolated from the generic standards path, and never silently selected by manufacturer identity. diff --git a/docs/engine-integration.md b/docs/engine-integration.md new file mode 100644 index 0000000..2912a50 --- /dev/null +++ b/docs/engine-integration.md @@ -0,0 +1,108 @@ +# ARIEC61850 sibling-engine integration + +## Ownership + +ARSVIN Publisher and ArSubsv Subscriber are derived applications. Reusable IEC 61850 protocol, Sampled Values, SCL, PCAP, transport, measurement, diagnostics, and evidence behavior is owned by the sibling [`masarray/ARIEC61850`](https://github.com/masarray/ARIEC61850) repository. + +The application repository owns WPF presentation, commands, workflow orchestration, application settings, branding, and packaging. The inactive `src/ARSVIN.Engine` directory is temporary migration material and is not an active project reference. + +## Pinned engine contract + +The paired revision is recorded in `engines/ARIEC61850.lock.json`. CI, CodeQL, local paired validation, packaging, and release automation must resolve the engine from that file and verify the exact 40-character commit SHA. A moving branch name is retained only as human context; it is not the reproducible build identity. + +Current paired revision for this draft: + +```text +ARIEC61850 ref: agent/sv-core-unification +ARIEC61850 commit: 143e6ca69986cd553405eec883a9928cdfda9367 +ARIEC61850 PR: #45 +``` + +## Migrated reusable contracts + +The paired engine branch now owns the reusable contracts required by both applications: + +- generic ASDU and raw `seqOfData` inspection; +- evidence-gated engineering scaling; +- timebase and `smpCnt` continuity analysis; +- semantic quality decoding and publisher quality encoding; +- explicit CT/VT and measurement-domain context; +- Sampled Values publisher evidence report contracts; +- local transmitter timing-health evidence; +- multi-ASDU Publisher profile/session behavior with bounded counter wrap; +- unified live/PCAP observation windows and stable logical stream keys; +- vendor-neutral profile evidence, confidence, and SCL-versus-wire comparison; +- Subscriber JSON/Markdown evidence and regression-comparison contracts. + +These contracts are implemented and tested in ARIEC61850. Application code consumes them; it must not maintain a second behavioral copy. + +## Paired local layout + +```text +C:\Git\ +├── ARIEC61850\ +└── arsvin\ +``` + +For the unmerged integration test: + +```powershell +git clone https://github.com/masarray/ARIEC61850.git C:\Git\ARIEC61850 +git -C C:\Git\ARIEC61850 fetch origin 143e6ca69986cd553405eec883a9928cdfda9367 +git -C C:\Git\ARIEC61850 checkout --detach 143e6ca69986cd553405eec883a9928cdfda9367 + +git clone https://github.com/masarray/arsvin.git C:\Git\arsvin +git -C C:\Git\arsvin switch agent/ariec-sibling-integration +``` + +Run the complete paired gate: + +```powershell +cd C:\Git\arsvin +.\scripts\test-paired-engine.ps1 +``` + +For a faster application-only iteration after the engine suite has already passed: + +```powershell +.\build.ps1 +``` + +The default sibling path can be overridden: + +```powershell +$env:ARIEC61850_ROOT = 'D:\Engineering\ARIEC61850' +.\scripts\test-paired-engine.ps1 +``` + +## Paired test sequence + +1. Verify the local engine checkout exactly matches the lock file. +2. Restore, build, and test `ARIEC61850`. +3. Build ARSVIN Publisher and ArSubsv against that exact checkout. +4. Run ARSVIN regression and coverage gates. +5. Run Publisher dry-run and PCAP generation, including `nofASDU=1` and one multi-ASDU case. +6. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. +7. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. +8. Export two Subscriber evidence reports and verify the shared comparator identifies source failover, health regression, and continuity changes. +9. Run authorized isolated live capture/transmit tests only after offline tests pass. +10. Preserve `artifacts/paired-validation/paired-validation.json` with the test notes. + +## Migration rules + +- New reusable `AR.Iec61850.*` protocol or analysis code goes to ARIEC61850 first. +- Application projects must not reference `ARSVIN.Engine`. +- Manufacturer identity must not select an SV parser, dataset order, scaling, quality interpretation, timebase, or health result. +- Engine changes require deterministic engine tests in ARIEC61850 and paired application regression tests in ARSVIN. +- CI records and verifies the exact paired engine commit. +- Changing the engine requires a reviewed lock-file update in the application PR. +- Lock files remain temporarily unlocked in this draft migration. NuGet lock files must be regenerated after both paired revisions are accepted. + +## Current draft pairing + +| Repository | Branch | Pull request | +|---|---|---| +| ARIEC61850 | `agent/sv-core-unification` | `masarray/ARIEC61850#45` | +| ARSVIN | `agent/ariec-sibling-integration` | `masarray/arsvin#51` | + +Neither pull request is intended for merge until paired local testing, pinned CI, CodeQL, packaging, and offline PCAP/SCL regression tests are complete. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json new file mode 100644 index 0000000..d3d5381 --- /dev/null +++ b/engines/ARIEC61850.lock.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "repository": "masarray/ARIEC61850", + "ref": "agent/sv-core-unification", + "commit": "fcc05de1f4b11560195e2589aa032e1d4c4b95f7", + "pairedPullRequest": 45, + "updatedAt": "2026-07-24T01:00:00Z", + "purpose": "Pinned sibling engine revision for paired local, CI, CodeQL, packaging, and release validation." +} diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1 index 1b11794..98ec446 100644 --- a/scripts/generate-sbom.ps1 +++ b/scripts/generate-sbom.ps1 @@ -27,8 +27,6 @@ function New-DeterministicUuidUrn { $uuidBytes = [byte[]]::new(16) [Array]::Copy($hash, $uuidBytes, $uuidBytes.Length) - - # RFC 4122 variant with a deterministic version-5-style identifier. $uuidBytes[6] = [byte](($uuidBytes[6] -band 0x0F) -bor 0x50) $uuidBytes[8] = [byte](($uuidBytes[8] -band 0x3F) -bor 0x80) @@ -44,7 +42,31 @@ function New-DeterministicUuidUrn { return "urn:uuid:$uuid" } +function Invoke-GitText { + param( + [Parameter(Mandatory)][string] $Repository, + [Parameter(Mandatory)][string[]] $Arguments + ) + + $output = & git -C $Repository @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C $Repository $($Arguments -join ' ') failed.`n$($output -join [Environment]::NewLine)" + } + return ($output -join '').Trim() +} + $root = Split-Path -Parent $PSScriptRoot +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' +if (-not (Test-Path $engineProject -PathType Leaf)) { + throw "ARIEC61850 sibling project was not found at $engineProject." +} + $applicationProjects = @( [ordered]@{ Name = 'ARSVIN Publisher' @@ -66,23 +88,15 @@ elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { $outputDirectory = Split-Path -Parent $OutputPath New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null -$sourceCommitOutput = & git -C $root rev-parse HEAD 2>&1 -if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the source commit.`n$($sourceCommitOutput -join [Environment]::NewLine)" -} -$sourceCommit = ($sourceCommitOutput -join '').Trim() - -$sourceTimestampOutput = & git -C $root show -s --format=%cI HEAD 2>&1 -if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the source commit timestamp.`n$($sourceTimestampOutput -join [Environment]::NewLine)" -} +$sourceCommit = Invoke-GitText -Repository $root -Arguments @('rev-parse', 'HEAD') +$engineCommit = Invoke-GitText -Repository $engineRoot -Arguments @('rev-parse', 'HEAD') +$engineRef = if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'local-checkout' } $sourceTimestamp = [DateTimeOffset]::Parse( - ($sourceTimestampOutput -join '').Trim(), + (Invoke-GitText -Repository $root -Arguments @('show', '-s', '--format=%cI', 'HEAD')), [System.Globalization.CultureInfo]::InvariantCulture ).ToUniversalTime().ToString('o') -$serialNumber = New-DeterministicUuidUrn -Value "https://github.com/masarray/arsvin|$Version|$sourceCommit" - +$serialNumber = New-DeterministicUuidUrn -Value "https://github.com/masarray/arsvin|$Version|$sourceCommit|$engineCommit" $packages = @{} foreach ($applicationProject in $applicationProjects) { @@ -172,7 +186,7 @@ if ($testOnlyPackages.Count -gt 0) { throw "Release SBOM unexpectedly contains test-only packages: $names" } -$components = foreach ($package in $sortedPackages) { +$nugetComponents = foreach ($package in $sortedPackages) { $escapedId = [Uri]::EscapeDataString([string] $package.Id) $escapedVersion = [Uri]::EscapeDataString([string] $package.Version) $purl = "pkg:nuget/$escapedId@$escapedVersion" @@ -197,6 +211,27 @@ $components = foreach ($package in $sortedPackages) { } } +$enginePurl = "pkg:generic/ARIEC61850@$engineCommit" +$engineComponent = [ordered]@{ + type = 'library' + 'bom-ref' = $enginePurl + name = 'ARIEC61850' + version = $engineCommit + purl = $enginePurl + licenses = @( + [ordered]@{ + license = [ordered]@{ id = 'GPL-3.0-or-later' } + } + ) + properties = @( + [ordered]@{ name = 'arsvin:source-repository'; value = 'https://github.com/masarray/ARIEC61850' }, + [ordered]@{ name = 'arsvin:source-ref'; value = $engineRef }, + [ordered]@{ name = 'arsvin:source-commit'; value = $engineCommit }, + [ordered]@{ name = 'arsvin:dependency-scope'; value = 'direct-source-project' } + ) +} + +$allComponents = @($engineComponent) + @($nugetComponents) $sbom = [ordered]@{ bomFormat = 'CycloneDX' specVersion = '1.5' @@ -210,7 +245,7 @@ $sbom = [ordered]@{ type = 'application' author = 'ARSVIN project' name = 'generate-sbom.ps1' - version = '1.2.0' + version = '2.0.0' } ) } @@ -221,24 +256,18 @@ $sbom = [ordered]@{ version = $Version licenses = @( [ordered]@{ - license = [ordered]@{ - id = 'Apache-2.0' - } + license = [ordered]@{ id = 'GPL-3.0-or-later' } } ) properties = @( - [ordered]@{ - name = 'arsvin:source-commit' - value = $sourceCommit - }, - [ordered]@{ - name = 'arsvin:included-applications' - value = ($applicationProjects.Name -join ', ') - } + [ordered]@{ name = 'arsvin:source-commit'; value = $sourceCommit }, + [ordered]@{ name = 'arsvin:engine-ref'; value = $engineRef }, + [ordered]@{ name = 'arsvin:engine-commit'; value = $engineCommit }, + [ordered]@{ name = 'arsvin:included-applications'; value = ($applicationProjects.Name -join ', ') } ) } } - components = @($components) + components = $allComponents } $json = $sbom | ConvertTo-Json -Depth 20 @@ -250,7 +279,8 @@ $json = $sbom | ConvertTo-Json -Depth 20 $written = Get-Item $OutputPath Write-Host "==> CycloneDX SBOM written: $($written.FullName)" -Write-Host " Source commit: $sourceCommit" +Write-Host " ARSVIN source commit: $sourceCommit" +Write-Host " ARIEC61850 source commit: $engineCommit" Write-Host " Serial number: $serialNumber" -Write-Host " Components: $($components.Count)" +Write-Host " Components: $($allComponents.Count)" Write-Host " Size: $($written.Length) bytes" diff --git a/scripts/publish-release.ps1 b/scripts/publish-release.ps1 index 6ee0250..341ecf1 100644 --- a/scripts/publish-release.ps1 +++ b/scripts/publish-release.ps1 @@ -18,6 +18,13 @@ $solution = Join-Path $root 'ARSVIN.sln' $publisherProject = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' $subscriberProject = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' $normalizedVersion = ($Version.Trim() -replace '^[vV]', '') if ($normalizedVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') { @@ -36,6 +43,19 @@ function Invoke-DotNet { } } +function Invoke-GitText { + param( + [Parameter(Mandatory)][string] $Repository, + [Parameter(Mandatory)][string[]] $Arguments + ) + + $output = & git -C $Repository @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C $Repository $($Arguments -join ' ') failed.`n$($output -join [Environment]::NewLine)" + } + return ($output -join '').Trim() +} + function Reset-Directory { param([Parameter(Mandatory)][string] $Path) if (Test-Path $Path) { @@ -86,7 +106,8 @@ function Copy-ReleaseDocumentation { 'known-limitations.md', 'safety-boundaries.md', 'subscriber-verification-app.md', - 'arsubsv-sv-scout-companion.md' + 'arsubsv-sv-scout-companion.md', + 'engine-integration.md' ) foreach ($document in $documents) { @@ -112,20 +133,32 @@ function Copy-ReleaseDocumentation { } } +if (-not (Test-Path $engineProject -PathType Leaf)) { + throw "ARIEC61850 sibling project was not found at $engineProject. Run build.ps1 first or clone the paired engine repository beside ARSVIN." +} + +$sourceCommit = Invoke-GitText -Repository $root -Arguments @('rev-parse', 'HEAD') +$engineCommit = Invoke-GitText -Repository $engineRoot -Arguments @('rev-parse', 'HEAD') +$engineRef = if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'local-checkout' } + +Write-Host "==> ARSVIN source commit: $sourceCommit" +Write-Host "==> ARIEC61850 ref: $engineRef" +Write-Host "==> ARIEC61850 commit: $engineCommit" + Reset-Directory $releaseRoot Reset-Directory $tempRoot Reset-Directory $portableRoot Reset-Directory $installerInput if (-not $SkipTests) { - Write-Host '==> Restoring locked solution graph and testing' - Invoke-DotNet -Arguments @('restore', $solution, '--locked-mode') + Write-Host '==> Restoring paired application/engine graph and testing' + Invoke-DotNet -Arguments @('restore', $solution) Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') } -Write-Host "==> Restoring locked publish graph for $Runtime" -Invoke-DotNet -Arguments @('restore', $publisherProject, '-r', $Runtime, '--locked-mode') -Invoke-DotNet -Arguments @('restore', $subscriberProject, '-r', $Runtime, '--locked-mode') +Write-Host "==> Restoring paired publish graph for $Runtime" +Invoke-DotNet -Arguments @('restore', $publisherProject, '-r', $Runtime) +Invoke-DotNet -Arguments @('restore', $subscriberProject, '-r', $Runtime) $commonPublishArguments = @( '-c', 'Release', @@ -175,6 +208,9 @@ Version: $normalizedVersion Runtime: $Runtime Publisher: ARSVIN.exe Subscriber: ArSubsv.exe +ARSVIN source commit: $sourceCommit +ARIEC61850 ref: $engineRef +ARIEC61850 source commit: $engineCommit Community license: GPL-3.0-or-later Commercial rights: separate negotiated agreement only "@ @@ -187,4 +223,4 @@ Compress-Archive -Path (Join-Path $portableRoot '*') -DestinationPath $portableZ Write-Host '==> Portable release artifacts' Get-ChildItem $releaseRoot -File | Select-Object Name, Length | Format-Table -AutoSize Write-Host "Release output: $releaseRoot" -Write-Host "Installer input: $installerInput" \ No newline at end of file +Write-Host "Installer input: $installerInput" diff --git a/scripts/resolve-engine-pin.ps1 b/scripts/resolve-engine-pin.ps1 new file mode 100644 index 0000000..ff670bc --- /dev/null +++ b/scripts/resolve-engine-pin.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = $(Split-Path -Parent $PSScriptRoot), + [switch] $AsObject +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$lockPath = Join-Path $RepositoryRoot 'engines\ARIEC61850.lock.json' +if (-not (Test-Path $lockPath -PathType Leaf)) { + throw "Pinned ARIEC61850 revision file was not found: $lockPath" +} + +try { + $document = Get-Content $lockPath -Raw | ConvertFrom-Json +} +catch { + throw "Pinned ARIEC61850 revision file is invalid JSON: $($_.Exception.Message)" +} + +if ([int] $document.schemaVersion -ne 1) { + throw "Unsupported ARIEC61850 lock schema version '$($document.schemaVersion)'." +} + +$repository = ([string] $document.repository).Trim() +$ref = ([string] $document.ref).Trim() +$commit = ([string] $document.commit).Trim().ToLowerInvariant() + +if ($repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw "Invalid ARIEC61850 repository '$repository'." +} +if ([string]::IsNullOrWhiteSpace($ref)) { + throw 'Pinned ARIEC61850 ref cannot be empty.' +} +if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Pinned ARIEC61850 commit '$commit' is not a full 40-character SHA." +} + +$result = [pscustomobject]@{ + SchemaVersion = 1 + Repository = $repository + RepositoryUrl = "https://github.com/$repository.git" + Ref = $ref + Commit = $commit + PairedPullRequest = [int] $document.pairedPullRequest + LockPath = $lockPath +} + +if ($AsObject) { + return $result +} + +$result | ConvertTo-Json -Depth 4 diff --git a/scripts/test-paired-engine.ps1 b/scripts/test-paired-engine.ps1 new file mode 100644 index 0000000..0986b48 --- /dev/null +++ b/scripts/test-paired-engine.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [string] $EngineRoot = $(if ($env:ARIEC61850_ROOT) { $env:ARIEC61850_ROOT } else { Join-Path (Split-Path -Parent $PSScriptRoot) '..\ARIEC61850' }), + [switch] $SkipEngineTests, + [switch] $SkipCoverage +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$pin = & (Join-Path $root 'scripts\resolve-engine-pin.ps1') -RepositoryRoot $root -AsObject +$engineRootFull = [System.IO.Path]::GetFullPath($EngineRoot) +$engineSolution = Join-Path $engineRootFull 'ARIEC61850.sln' + +if (-not (Test-Path $engineSolution -PathType Leaf)) { + throw "ARIEC61850 sibling solution was not found: $engineSolution" +} + +$engineSha = (& git -C $engineRootFull rev-parse HEAD 2>&1 | Select-Object -First 1).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve ARIEC61850 HEAD at $engineRootFull." +} +if ($engineSha -ne $pin.Commit) { + throw "Paired engine mismatch. Expected $($pin.Commit), found $engineSha. Checkout the pinned commit before testing." +} + +$env:ARIEC61850_ROOT = $engineRootFull +$env:ARIEC61850_REF = $pin.Ref +$env:ARIEC61850_COMMIT = $pin.Commit + +function Invoke-DotNet { + param([Parameter(Mandatory)][string[]] $Arguments) + & dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +if (-not $SkipEngineTests) { + Write-Host '==> Restoring and testing pinned ARIEC61850' + Invoke-DotNet @('restore', $engineSolution) + Invoke-DotNet @('build', $engineSolution, '-c', 'Release', '--no-restore', '-warnaserror') + Invoke-DotNet @('test', $engineSolution, '-c', 'Release', '--no-build', '/p:TreatWarningsAsErrors=true') +} + +Write-Host '==> Building and testing ARSVIN applications against pinned engine' +& (Join-Path $root 'build.ps1') -EngineCommit $pin.Commit -EngineRef $pin.Ref +if ($LASTEXITCODE -ne 0) { + throw "Paired ARSVIN build failed with exit code $LASTEXITCODE." +} + +if (-not $SkipCoverage) { + Write-Host '==> Running ARSVIN integration coverage gates against pinned engine' + & (Join-Path $root 'scripts\test-with-coverage.ps1') ` + -MinimumWholeEngineCoveredLines 3000 ` + -MinimumProtocolCoreCoveredLines 2300 ` + -MinimumLineCoverage 72.5 ` + -NoRestore + if ($LASTEXITCODE -ne 0) { + throw "Paired coverage validation failed with exit code $LASTEXITCODE." + } +} + +$applicationSha = (& git -C $root rev-parse HEAD 2>&1 | Select-Object -First 1).Trim() +$reportRoot = Join-Path $root 'artifacts\paired-validation' +New-Item -ItemType Directory -Path $reportRoot -Force | Out-Null +[ordered]@{ + schemaVersion = 1 + result = 'passed' + applicationRepository = 'masarray/arsvin' + applicationCommit = $applicationSha + engineRepository = $pin.Repository + engineRef = $pin.Ref + engineCommit = $engineSha + engineTestsExecuted = (-not $SkipEngineTests) + coverageExecuted = (-not $SkipCoverage) + generatedAt = [DateTimeOffset]::UtcNow.ToString('O') +} | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $reportRoot 'paired-validation.json') -Encoding utf8 + +Write-Host "==> Paired validation passed: ARSVIN $applicationSha + ARIEC61850 $engineSha" diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index d95d7d4..288f089 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -3,8 +3,11 @@ param( [ValidateRange(0, 100)] [double] $MinimumLineCoverage = 72.5, - [ValidateRange(0, 100)] - [double] $MinimumWholeEngineLineCoverage = 14.25, + [ValidateRange(1, [int]::MaxValue)] + [int] $MinimumWholeEngineCoveredLines = 3000, + + [ValidateRange(1, [int]::MaxValue)] + [int] $MinimumProtocolCoreCoveredLines = 2300, [switch] $NoRestore ) @@ -33,13 +36,15 @@ if ($NoRestore) { $arguments += '--no-restore' } -# Instrument the complete production engine. CI protects both the truthful -# whole-engine baseline and the higher protocol-core regression baseline. +# ARSVIN tests execute against the pinned sibling ARIEC61850 source of truth. +# The engine repository owns its full-suite percentage gate. This integration gate checks: +# 1. the application-consumed protocol core remains strongly covered; and +# 2. the absolute amount of exercised production code does not regress when the engine grows. $arguments += @( '/p:RestoreLockedMode=true', '/p:TreatWarningsAsErrors=true', '/p:CollectCoverage=true', - '/p:Include=[ARSVIN.Engine]*', + '/p:Include=[AR.Iec61850]*', '/p:ExcludeAssembliesWithoutSources=None', '/p:CoverletOutputFormat=cobertura', "/p:CoverletOutput=$coveragePrefix", @@ -64,6 +69,7 @@ if (-not (Test-Path $coverageFile -PathType Leaf)) { [xml] $coverage = Get-Content $coverageFile -Raw $overallLineRateText = [string] $coverage.coverage.'line-rate' $overallLinesValidText = [string] $coverage.coverage.'lines-valid' +$overallLinesCoveredText = [string] $coverage.coverage.'lines-covered' if ([string]::IsNullOrWhiteSpace($overallLineRateText)) { throw 'Cobertura report does not contain a root line-rate value.' } @@ -73,6 +79,11 @@ if (-not [int]::TryParse($overallLinesValidText, [ref] $overallLinesValid) -or $ throw 'Coverage report contains no instrumented production source lines.' } +$overallLinesCovered = 0 +if (-not [int]::TryParse($overallLinesCoveredText, [ref] $overallLinesCovered) -or $overallLinesCovered -le 0) { + throw 'Coverage report contains no covered production source lines.' +} + $overallLineRate = [double]::Parse( $overallLineRateText, [System.Globalization.CultureInfo]::InvariantCulture @@ -130,37 +141,46 @@ if ($coreLinesValid -le 0) { $coreLineCoverage = [Math]::Round(($coreLinesCovered / $coreLinesValid) * 100, 2) -Write-Host "Whole engine lines: $overallLinesValid" -Write-Host "Whole engine line coverage: $overallLineCoverage%" -Write-Host "Whole engine minimum required: $MinimumWholeEngineLineCoverage%" +Write-Host "ARIEC61850 instrumented lines: $overallLinesValid" +Write-Host "ARIEC61850 covered lines exercised by ARSVIN: $overallLinesCovered" +Write-Host "Minimum covered production lines: $MinimumWholeEngineCoveredLines" +Write-Host "Informational whole-engine line coverage: $overallLineCoverage%" Write-Host "Protocol core files: $($coreFiles.Count)" Write-Host "Protocol core lines: $coreLinesValid" Write-Host "Protocol core covered lines: $coreLinesCovered" +Write-Host "Minimum protocol-core covered lines: $MinimumProtocolCoreCoveredLines" Write-Host "Protocol core line coverage: $coreLineCoverage%" -Write-Host "Protocol core minimum required: $MinimumLineCoverage%" +Write-Host "Protocol core minimum percentage: $MinimumLineCoverage%" Write-Host "Coverage report: $coverageFile" if ($env:GITHUB_STEP_SUMMARY) { @" -## Test coverage +## ARSVIN integration coverage against pinned ARIEC61850 + +The ARIEC61850 repository owns the full-engine percentage gate. This paired gate measures the reusable code actually exercised by ARSVIN Publisher and ArSubsv. | Metric | Result | |---|---:| -| Whole `ARSVIN.Engine` instrumented lines | **$overallLinesValid** | -| Whole engine line coverage | **$overallLineCoverage%** | -| Whole-engine regression floor | **$MinimumWholeEngineLineCoverage%** | +| Whole `AR.Iec61850` instrumented lines | **$overallLinesValid** | +| Production lines exercised by ARSVIN | **$overallLinesCovered** | +| Minimum exercised production lines | **$MinimumWholeEngineCoveredLines** | +| Informational whole-engine line coverage | **$overallLineCoverage%** | | Tested protocol-core files | **$($coreFiles.Count)** | | Protocol-core instrumented lines | **$coreLinesValid** | | Protocol-core covered lines | **$coreLinesCovered** | +| Minimum protocol-core covered lines | **$MinimumProtocolCoreCoveredLines** | | Protocol-core line coverage | **$coreLineCoverage%** | -| Protocol-core regression floor | **$MinimumLineCoverage%** | +| Protocol-core percentage floor | **$MinimumLineCoverage%** | | Report | `artifacts/test-results/coverage.cobertura.xml` | "@ | Add-Content $env:GITHUB_STEP_SUMMARY } $coverageFailures = [System.Collections.Generic.List[string]]::new() -if ($overallLineCoverage -lt $MinimumWholeEngineLineCoverage) { - $coverageFailures.Add("Whole-engine line coverage $overallLineCoverage% is below the required $MinimumWholeEngineLineCoverage%.") +if ($overallLinesCovered -lt $MinimumWholeEngineCoveredLines) { + $coverageFailures.Add("ARSVIN exercised $overallLinesCovered production lines, below the required $MinimumWholeEngineCoveredLines.") +} +if ($coreLinesCovered -lt $MinimumProtocolCoreCoveredLines) { + $coverageFailures.Add("ARSVIN exercised $coreLinesCovered protocol-core lines, below the required $MinimumProtocolCoreCoveredLines.") } if ($coreLineCoverage -lt $MinimumLineCoverage) { $coverageFailures.Add("Protocol-core line coverage $coreLineCoverage% is below the required $MinimumLineCoverage%.") diff --git a/scripts/validate-engine-ownership.py b/scripts/validate-engine-ownership.py new file mode 100644 index 0000000..a45d2cf --- /dev/null +++ b/scripts/validate-engine-ownership.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Validate that ARSVIN applications consume ARIEC61850 instead of an embedded engine. + +The legacy src/ARSVIN.Engine directory may remain temporarily during migration, but it must +not be referenced by active projects or the solution. Reusable protocol namespaces must not +be reintroduced in application source folders. Application-specific namespaces such as +AR.Iec61850.SvPublisher remain presentation ownership and are allowed. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ACTIVE_PROJECTS = ( + ROOT / "src" / "ARSVIN" / "ARSVIN.csproj", + ROOT / "src" / "ARSVIN.Subscriber" / "ARSVIN.Subscriber.csproj", + ROOT / "tests" / "ARSVIN.Tests" / "ARSVIN.Tests.csproj", +) +SOLUTION = ROOT / "ARSVIN.sln" +LEGACY_ENGINE = ROOT / "src" / "ARSVIN.Engine" +ENGINE_NAMESPACE_PREFIXES = ( + "AR.Iec61850.Asn1", + "AR.Iec61850.Capture", + "AR.Iec61850.Diagnostics", + "AR.Iec61850.Ethernet", + "AR.Iec61850.Goose", + "AR.Iec61850.Mms", + "AR.Iec61850.Reporting", + "AR.Iec61850.SampledValues", + "AR.Iec61850.Scl", + "AR.Iec61850.Transports", +) +NAMESPACE_PATTERN = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE) + + +def read(path: Path) -> str: + if not path.is_file(): + raise RuntimeError(f"Required file is missing: {path.relative_to(ROOT)}") + return path.read_text(encoding="utf-8-sig") + + +def main() -> int: + errors: list[str] = [] + + for project in ACTIVE_PROJECTS: + text = read(project) + relative = project.relative_to(ROOT) + if "ARSVIN.Engine" in text: + errors.append(f"{relative}: active project still references ARSVIN.Engine") + if "$(ARIEC61850_CORE_PROJECT)" not in text: + errors.append(f"{relative}: missing ARIEC61850 core ProjectReference") + if "$(ARIEC61850_NPCAP_PROJECT)" not in text: + errors.append(f"{relative}: missing ARIEC61850 Npcap ProjectReference") + + solution_text = read(SOLUTION) + if "ARSVIN.Engine" in solution_text: + errors.append("ARSVIN.sln: embedded ARSVIN.Engine is still an active solution project") + + application_roots = (ROOT / "src" / "ARSVIN", ROOT / "src" / "ARSVIN.Subscriber") + for application_root in application_roots: + for source in application_root.rglob("*.cs"): + text = source.read_text(encoding="utf-8-sig", errors="replace") + for match in NAMESPACE_PATTERN.finditer(text): + namespace = match.group(1) + if namespace.startswith(ENGINE_NAMESPACE_PREFIXES): + errors.append( + f"{source.relative_to(ROOT)}: reusable namespace {namespace} belongs in the sibling engine repository" + ) + + legacy_count = 0 + if LEGACY_ENGINE.is_dir(): + legacy_count = sum(1 for path in LEGACY_ENGINE.rglob("*.cs") if path.is_file()) + + report_lines = [ + "ARSVIN engine ownership validation", + "active engine: sibling masarray/ARIEC61850", + f"active application projects checked: {len(ACTIVE_PROJECTS)}", + f"inactive legacy embedded C# files remaining: {legacy_count}", + ] + + if errors: + report_lines.append("result: FAILED") + report_lines.extend(f"ERROR: {error}" for error in errors) + else: + report_lines.append("result: PASSED") + + report = "\n".join(report_lines) + "\n" + print(report, end="") + + artifact = ROOT / "artifacts" / "engine-ownership-report.txt" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text(report, encoding="utf-8") + + return 1 if errors else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs deleted file mode 100644 index cfcd6de..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace AR.Iec61850.SampledValues; - -public enum SampleCounterMode -{ - FreeRun, - SecondAligned -} - -public static class SampleCounterPolicy -{ - public static ushort InitialSampleCount(DateTimeOffset timestamp, double sampleRateHz, ushort? wrap, SampleCounterMode mode) - { - if (mode == SampleCounterMode.FreeRun || sampleRateHz <= 0) - return 0; - - var samplesPerSecond = wrap is > 1 ? wrap.Value : sampleRateHz; - if (samplesPerSecond <= 0) - return 0; - - var seconds = timestamp.TimeOfDay.TotalSeconds; - var fraction = seconds - Math.Floor(seconds); - var sample = (long)Math.Floor(fraction * samplesPerSecond); - var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1L; - sample %= modulo; - if (sample < 0) - sample += modulo; - return (ushort)sample; - } - - public static ushort Increment(ushort current, ushort? wrap, int step = 1) - { - if (step <= 0) - return current; - - var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; - return (ushort)((current + step) % modulo); - } -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs deleted file mode 100644 index 63f03cc..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Buffers.Binary; - -namespace AR.Iec61850.SampledValues; - -/// -/// Compact IEC 61850 quality bit helper used by the SV publisher payload builder. -/// The class intentionally exposes only the common simulation knobs needed by a publisher. -/// -public readonly record struct SampledValueQuality( - SampledValueValidity Validity, - bool Overflow = false, - bool OutOfRange = false, - bool BadReference = false, - bool Oscillatory = false, - bool Failure = false, - bool OldData = false, - bool Inconsistent = false, - bool Inaccurate = false, - bool Test = false, - bool OperatorBlocked = false) -{ - public static SampledValueQuality Good { get; } = new(SampledValueValidity.Good); - public static SampledValueQuality Invalid { get; } = new(SampledValueValidity.Invalid); - public static SampledValueQuality Questionable { get; } = new(SampledValueValidity.Questionable); - public static SampledValueQuality TestGood { get; } = new(SampledValueValidity.Good, Test: true); - public static SampledValueQuality OldDataGood { get; } = new(SampledValueValidity.Good, OldData: true); - public static SampledValueQuality OperatorBlockedGood { get; } = new(SampledValueValidity.Good, OperatorBlocked: true); - - public uint ToUInt32() - { - var value = (uint)Validity; - if (Overflow) value |= 1u << 2; - if (OutOfRange) value |= 1u << 3; - if (BadReference) value |= 1u << 4; - if (Oscillatory) value |= 1u << 5; - if (Failure) value |= 1u << 6; - if (OldData) value |= 1u << 7; - if (Inconsistent) value |= 1u << 8; - if (Inaccurate) value |= 1u << 9; - if (Test) value |= 1u << 11; - if (OperatorBlocked) value |= 1u << 12; - return value; - } - - public byte[] ToBytes(int width = 4) - { - if (width <= 0) - return []; - - Span encoded = stackalloc byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(encoded, ToUInt32()); - - if (width == 4) - return encoded.ToArray(); - - var result = new byte[width]; - encoded[..Math.Min(4, width)].CopyTo(result); - return result; - } - - public static SampledValueQuality FromUInt32(uint encoded) - { - var validity = (SampledValueValidity)(encoded & 0x03); - return new SampledValueQuality( - validity, - Overflow: (encoded & (1u << 2)) != 0, - OutOfRange: (encoded & (1u << 3)) != 0, - BadReference: (encoded & (1u << 4)) != 0, - Oscillatory: (encoded & (1u << 5)) != 0, - Failure: (encoded & (1u << 6)) != 0, - OldData: (encoded & (1u << 7)) != 0, - Inconsistent: (encoded & (1u << 8)) != 0, - Inaccurate: (encoded & (1u << 9)) != 0, - Test: (encoded & (1u << 11)) != 0, - OperatorBlocked: (encoded & (1u << 12)) != 0); - } -} - -public enum SampledValueValidity : uint -{ - Good = 0, - Invalid = 1, - Reserved = 2, - Questionable = 3 -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs deleted file mode 100644 index 5e93ce7..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs +++ /dev/null @@ -1,69 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public sealed record SampledValuesFramePreview( - string ControlBlockReference, - string SvId, - string DataSetReference, - ushort AppId, - MacAddress Destination, - VlanTag? Vlan, - ushort NoAsdu, - int PayloadBytesPerAsdu, - double SampleRateHz, - double PublicationRateHz, - int EstimatedEthernetBytes, - double EstimatedBandwidthBitsPerSecond) -{ - public string Summary => - $"{ControlBlockReference}: svID={SvId}, APPID=0x{AppId:X4}, dst={Destination}, " + - $"{(Vlan is null ? "untagged" : $"VID={Vlan.Value.VlanId}/PCP={Vlan.Value.PriorityCodePoint}")}, " + - $"nofASDU={NoAsdu}, sample={SampleRateHz:0.###} fps, publish={PublicationRateHz:0.###} fps, " + - $"payload={PayloadBytesPerAsdu} B/ASDU, frame≈{EstimatedEthernetBytes} B, bw≈{EstimatedBandwidthBitsPerSecond / 1000.0:0.###} kbps"; - - public static SampledValuesFramePreview FromStream(SclSampledValuesStream stream, double sampleRateHz) - { - ArgumentNullException.ThrowIfNull(stream); - var profile = SampledValuesPublisherProfile.Create(stream); - return FromProfile(profile, sampleRateHz); - } - - public static SampledValuesFramePreview FromProfile(SampledValuesPublisherProfile profile, double sampleRateHz) - { - ArgumentNullException.ThrowIfNull(profile); - var noAsdu = profile.AsduPerFrame; - var publicationRateHz = SampledValuesPublisherProfile.ResolvePublicationRate(sampleRateHz, noAsdu); - var estimatedBytes = EstimateEthernetFrameBytes(profile, noAsdu); - return new SampledValuesFramePreview( - profile.Stream.ControlBlockReference, - profile.Stream.SvId, - profile.Stream.DataSetReference, - profile.AppId, - profile.Destination, - profile.Vlan, - noAsdu, - profile.PayloadLayout.PayloadByteLength, - sampleRateHz, - publicationRateHz, - estimatedBytes, - estimatedBytes * 8.0 * publicationRateHz); - } - - public static int EstimateEthernetFrameBytes(SampledValuesPublisherProfile profile, ushort? noAsduOverride = null) - { - ArgumentNullException.ThrowIfNull(profile); - var noAsdu = noAsduOverride ?? profile.AsduPerFrame; - var payloads = Enumerable.Range(0, noAsdu) - .Select(_ => new byte[profile.PayloadLayout.PayloadByteLength]) - .ToArray(); - var frame = profile.BuildEthernetFrame( - MacAddress.Parse("02:00:00:00:00:01"), - sampleCount: 0, - samplePayloads: payloads, - referenceTime: null, - sampleSynchronization: 2); - return frame.Length; - } -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs deleted file mode 100644 index c7deaf9..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs +++ /dev/null @@ -1,304 +0,0 @@ -using AR.Iec61850.Asn1; -using AR.Iec61850.Mms; -using System.Buffers.Binary; -using System.Globalization; - -namespace AR.Iec61850.SampledValues; - -public static class SampledValuesPayloadBuilder -{ - public static byte[] BuildPayload(SampledValuesPayloadLayout layout, IReadOnlyList values) - { - ArgumentNullException.ThrowIfNull(layout); - ArgumentNullException.ThrowIfNull(values); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - if (values.Count != layout.Elements.Count) - throw new ArgumentException($"SV payload value count mismatch. Expected {layout.Elements.Count}, got {values.Count}.", nameof(values)); - - var payload = new byte[layout.PayloadByteLength]; - for (var i = 0; i < layout.Elements.Count; i++) - { - var element = layout.Elements[i]; - WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[i]); - } - - return payload; - } - - public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec61850UtcTime? timestamp = null, SampledValueQuality? quality = null) - { - ArgumentNullException.ThrowIfNull(layout); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - var payload = new byte[layout.PayloadByteLength]; - foreach (var element in layout.Elements) - { - if (element.Kind == SampledValuePayloadElementKind.Timestamp && timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(payload.AsSpan(element.Offset, element.Width)); - - if (element.Kind == SampledValuePayloadElementKind.Quality) - (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(payload.AsSpan(element.Offset, element.Width)); - } - - return payload; - } - - public static byte[] BuildDemoPayload( - SampledValuesPayloadLayout layout, - long sampleIndex, - double sampleRateHz, - double nominalHz, - Iec61850UtcTime? timestamp = null, - SampledValueQuality? quality = null) - { - ArgumentNullException.ThrowIfNull(layout); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - - if (nominalHz <= 0) - throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0."); - - var payload = new byte[layout.PayloadByteLength]; - foreach (var element in layout.Elements) - { - var destination = payload.AsSpan(element.Offset, element.Width); - if (element.Kind == SampledValuePayloadElementKind.Quality) - { - (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); - continue; - } - - if (element.Kind == SampledValuePayloadElementKind.BitString || - element.Kind == SampledValuePayloadElementKind.EntryTime) - { - continue; - } - - if (element.Kind == SampledValuePayloadElementKind.Timestamp) - { - if (timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(destination); - continue; - } - - var value = ComputeDemoValue(element, sampleIndex, sampleRateHz, nominalHz); - WriteNumeric(destination, element.Kind, value); - } - - return payload; - } - - private static void WriteValue(Span destination, SampledValuePayloadElement element, MmsDataValue value) - { - switch (element.Kind) - { - case SampledValuePayloadElementKind.Boolean: - destination[0] = value.Kind == MmsDataKind.Boolean && value.Value is true ? (byte)1 : (byte)0; - return; - - case SampledValuePayloadElementKind.Int8: - case SampledValuePayloadElementKind.Int16: - case SampledValuePayloadElementKind.Int32: - case SampledValuePayloadElementKind.Int64: - case SampledValuePayloadElementKind.Enum: - WriteNumeric(destination, element.Kind, ToInt64(value)); - return; - - case SampledValuePayloadElementKind.UInt8: - case SampledValuePayloadElementKind.UInt16: - case SampledValuePayloadElementKind.UInt24: - case SampledValuePayloadElementKind.UInt32: - case SampledValuePayloadElementKind.UInt64: - WriteUnsigned(destination, element.Kind, ToUInt64(value)); - return; - - case SampledValuePayloadElementKind.Float32: - WriteFloat32(destination, ToSingle(value)); - return; - - case SampledValuePayloadElementKind.Float64: - WriteFloat64(destination, ToDouble(value)); - return; - - case SampledValuePayloadElementKind.Quality: - case SampledValuePayloadElementKind.BitString: - case SampledValuePayloadElementKind.OctetString: - case SampledValuePayloadElementKind.VisibleString: - case SampledValuePayloadElementKind.EntryTime: - CopyRawPayloadBytes(destination, value); - return; - - case SampledValuePayloadElementKind.Timestamp: - if (value.Kind != MmsDataKind.UtcTime || value.Value is not Iec61850UtcTime utc) - throw new ArgumentException($"SV element {element.SignalReference} expects UTC time."); - BerWriter.EncodeUtcTime(utc.Value, utc.Quality).CopyTo(destination); - return; - - default: - throw new NotSupportedException($"SV payload element kind {element.Kind} is not supported."); - } - } - - private static void WriteNumeric(Span destination, SampledValuePayloadElementKind kind, long value) - { - switch (kind) - { - case SampledValuePayloadElementKind.Int8: - destination[0] = unchecked((byte)(sbyte)value); - break; - case SampledValuePayloadElementKind.Int16: - BinaryPrimitives.WriteInt16BigEndian(destination, checked((short)value)); - break; - case SampledValuePayloadElementKind.Int32: - case SampledValuePayloadElementKind.Enum: - BinaryPrimitives.WriteInt32BigEndian(destination, checked((int)value)); - break; - case SampledValuePayloadElementKind.Int64: - BinaryPrimitives.WriteInt64BigEndian(destination, value); - break; - case SampledValuePayloadElementKind.UInt8: - case SampledValuePayloadElementKind.UInt16: - case SampledValuePayloadElementKind.UInt24: - case SampledValuePayloadElementKind.UInt32: - case SampledValuePayloadElementKind.UInt64: - WriteUnsigned(destination, kind, checked((ulong)value)); - break; - case SampledValuePayloadElementKind.Float32: - WriteFloat32(destination, value); - break; - case SampledValuePayloadElementKind.Float64: - WriteFloat64(destination, value); - break; - default: - throw new NotSupportedException($"SV numeric kind {kind} is not supported."); - } - } - - private static void WriteUnsigned(Span destination, SampledValuePayloadElementKind kind, ulong value) - { - switch (kind) - { - case SampledValuePayloadElementKind.UInt8: - destination[0] = checked((byte)value); - break; - case SampledValuePayloadElementKind.UInt16: - BinaryPrimitives.WriteUInt16BigEndian(destination, checked((ushort)value)); - break; - case SampledValuePayloadElementKind.UInt24: - if (value > 0xFFFFFF) - throw new OverflowException("UINT24 value is out of range."); - destination[0] = (byte)((value >> 16) & 0xFF); - destination[1] = (byte)((value >> 8) & 0xFF); - destination[2] = (byte)(value & 0xFF); - break; - case SampledValuePayloadElementKind.UInt32: - BinaryPrimitives.WriteUInt32BigEndian(destination, checked((uint)value)); - break; - case SampledValuePayloadElementKind.UInt64: - BinaryPrimitives.WriteUInt64BigEndian(destination, value); - break; - default: - throw new NotSupportedException($"SV unsigned kind {kind} is not supported."); - } - } - - private static void WriteFloat32(Span destination, double value) - => BinaryPrimitives.WriteUInt32BigEndian(destination, unchecked((uint)BitConverter.SingleToInt32Bits((float)value))); - - private static void WriteFloat64(Span destination, double value) - => BinaryPrimitives.WriteUInt64BigEndian(destination, unchecked((ulong)BitConverter.DoubleToInt64Bits(value))); - - private static void CopyRawPayloadBytes(Span destination, MmsDataValue value) - { - var raw = value.RawValue.ToArray(); - if (value.Kind == MmsDataKind.BitString && raw.Length == destination.Length + 1) - raw = raw[1..]; - - if (raw.Length > destination.Length) - throw new ArgumentException($"SV raw value has {raw.Length} bytes but the payload slot has {destination.Length} bytes."); - - raw.CopyTo(destination); - } - - private static long ToInt64(MmsDataValue value) - => value.Kind switch - { - MmsDataKind.Integer when value.Value is long signed => signed, - MmsDataKind.Unsigned when value.Value is ulong unsigned => checked((long)unsigned), - MmsDataKind.Boolean when value.Value is bool boolean => boolean ? 1 : 0, - _ => Convert.ToInt64(value.Value, CultureInfo.InvariantCulture) - }; - - private static ulong ToUInt64(MmsDataValue value) - => value.Kind switch - { - MmsDataKind.Unsigned when value.Value is ulong unsigned => unsigned, - MmsDataKind.Integer when value.Value is long signed => checked((ulong)signed), - MmsDataKind.Boolean when value.Value is bool boolean => boolean ? 1UL : 0UL, - _ => Convert.ToUInt64(value.Value, CultureInfo.InvariantCulture) - }; - - private static float ToSingle(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f - : Convert.ToSingle(value.Value, CultureInfo.InvariantCulture); - - private static double ToDouble(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f - : Convert.ToDouble(value.Value, CultureInfo.InvariantCulture); - - private static long ComputeDemoValue(SampledValuePayloadElement element, long sampleIndex, double sampleRateHz, double nominalHz) - { - var amplitude = ResolveDemoAmplitude(element); - var angle = (2.0 * Math.PI * nominalHz * sampleIndex / sampleRateHz) + ResolvePhaseRadians(element.SignalReference); - return (long)Math.Round(amplitude * Math.Sin(angle)); - } - - private static int ResolveDemoAmplitude(SampledValuePayloadElement element) - { - var reference = element.SignalReference; - if (reference.Contains("/TVTR", StringComparison.OrdinalIgnoreCase) || - reference.Contains(".Vol", StringComparison.OrdinalIgnoreCase)) - return 100_000; - - if (reference.Contains("/TCTR", StringComparison.OrdinalIgnoreCase) || - reference.Contains(".Amp", StringComparison.OrdinalIgnoreCase)) - return 10_000; - - return 1_000; - } - - private static double ResolvePhaseRadians(string signalReference) - { - var slash = signalReference.LastIndexOf('/'); - var dot = signalReference.IndexOf('.', slash + 1); - if (slash < 0 || dot <= slash) - return 0; - - var logicalNode = signalReference[(slash + 1)..dot]; - var digits = new string(logicalNode.Reverse().TakeWhile(char.IsDigit).Reverse().ToArray()); - if (!int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var instance)) - return 0; - - return instance switch - { - 2 => -2.0 * Math.PI / 3.0, - 3 => 2.0 * Math.PI / 3.0, - _ => 0 - }; - } - - private static string BuildUnsupportedLayoutMessage(SampledValuesPayloadLayout layout) - => "SV payload layout has unsupported DataSet entries: " + - string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}")); -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs deleted file mode 100644 index 6211cf5..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs +++ /dev/null @@ -1,13 +0,0 @@ -using AR.Iec61850.Capture; - -namespace AR.Iec61850.SampledValues; - -public static class SampledValuesPcapExporter -{ - public static void WriteGeneratedFrames(string filePath, IEnumerable<(DateTimeOffset Timestamp, byte[] Frame)> frames) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - ArgumentNullException.ThrowIfNull(frames); - PcapWriter.WriteAll(filePath, frames.Select(frame => new PcapPacket(frame.Timestamp, frame.Frame))); - } -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs deleted file mode 100644 index 964e29e..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs +++ /dev/null @@ -1,232 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Mms; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public sealed class SampledValuesPublisherProfile -{ - public const ushort MaxAsduPerFrame = 8; - private SampledValuesPublisherProfile( - SclSampledValuesStream stream, - ushort appId, - MacAddress destination, - VlanTag? vlan) - { - Stream = stream; - AppId = appId; - Destination = destination; - Vlan = vlan; - PayloadLayout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); - } - - public SclSampledValuesStream Stream { get; } - public ushort AppId { get; } - public MacAddress Destination { get; } - public VlanTag? Vlan { get; } - public SampledValuesPayloadLayout PayloadLayout { get; } - public IReadOnlyList Entries => Stream.Entries; - public ushort AsduPerFrame => ResolveAsduPerFrame(Stream); - - public static IReadOnlyList CreateMany(SclDocument document) - { - ArgumentNullException.ThrowIfNull(document); - return document.SampledValuesStreams.Select(Create).ToArray(); - } - - public static SampledValuesPublisherProfile FromScl(SclDocument document, string? controlBlockReference = null) - { - ArgumentNullException.ThrowIfNull(document); - - var stream = string.IsNullOrWhiteSpace(controlBlockReference) - ? document.SampledValuesStreams.FirstOrDefault() - : document.SampledValuesStreams.FirstOrDefault(s => string.Equals(s.ControlBlockReference, controlBlockReference, StringComparison.OrdinalIgnoreCase)); - - if (stream is null) - throw new SclProfileException("No matching SampledValueControl stream was found in the SCL document."); - - return Create(stream); - } - - public SampledValuesFrame CreateFrame( - MacAddress source, - ushort sampleCount, - ReadOnlySpan samplePayload, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - if (AsduPerFrame != 1) - throw new InvalidOperationException($"SV {Stream.ControlBlockReference} declares nofASDU={AsduPerFrame}. Use the multi-ASDU CreateFrame overload."); - - return CreateFrame( - source, - sampleCount, - new[] { samplePayload.ToArray() }, - referenceTime, - sampleSynchronization, - sampleCounterWrap); - } - - public SampledValuesFrame CreateFrame( - MacAddress source, - ushort sampleCount, - IReadOnlyList samplePayloads, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - ArgumentNullException.ThrowIfNull(samplePayloads); - ValidateAsduPayloadBatch(samplePayloads); - - if (sampleCounterWrap is 1) - throw new ArgumentOutOfRangeException(nameof(sampleCounterWrap), "SV sample counter wrap must be greater than 1 when supplied."); - - var asdus = new List(samplePayloads.Count); - for (var i = 0; i < samplePayloads.Count; i++) - { - asdus.Add(new SampledValueAsdu - { - SvId = Stream.SvId, - DataSetReference = Stream.DataSetReference, - SampleCount = SampleCounterPolicy.Increment(sampleCount, sampleCounterWrap, i), - ConfigurationRevision = Stream.ConfigurationRevision, - ReferenceTime = referenceTime, - SampleSynchronization = sampleSynchronization, - SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, - SampleMode = TryMapSampleMode(Stream.SampleMode), - SamplePayload = samplePayloads[i].ToArray() - }); - } - - return new SampledValuesFrame - { - Destination = Destination, - Source = source, - Vlan = Vlan, - AppId = AppId, - Pdu = new SampledValuesPdu { Asdus = asdus } - }; - } - - public byte[] BuildEthernetFrame( - MacAddress source, - ushort sampleCount, - ReadOnlySpan samplePayload, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - return SampledValuesFrameBuilder.BuildEthernetFrame( - CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization, sampleCounterWrap)); - } - - public byte[] BuildEthernetFrame( - MacAddress source, - ushort sampleCount, - IReadOnlyList samplePayloads, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - return SampledValuesFrameBuilder.BuildEthernetFrame( - CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization, sampleCounterWrap)); - } - - public byte[] BuildPayload(IReadOnlyList values) - => SampledValuesPayloadBuilder.BuildPayload(PayloadLayout, values); - - public byte[] BuildDefaultPayload(Iec61850UtcTime? timestamp = null) - => SampledValuesPayloadBuilder.BuildDefaultPayload(PayloadLayout, timestamp); - - public byte[] BuildDemoPayload( - long sampleIndex, - double sampleRateHz, - double nominalHz, - Iec61850UtcTime? timestamp = null, - SampledValueQuality? quality = null) - => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp, quality); - - public ushort? ResolveSampleCounterWrap(double nominalFrequencyHz) - { - var mode = TryMapSampleMode(Stream.SampleMode); - if (Stream.SampleRate == 0) - return null; - - var samplesPerSecond = mode switch - { - 0 => Stream.SampleRate * nominalFrequencyHz, - 1 => Stream.SampleRate, - _ => 0 - }; - - if (samplesPerSecond <= 0 || samplesPerSecond > ushort.MaxValue) - return null; - - return (ushort)Math.Round(samplesPerSecond); - } - - public static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) - { - if (!stream.Address.AppId.HasValue) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid APPID in SCL Communication/SMV."); - - if (!stream.Address.DestinationMac.HasValue) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid destination MAC in SCL Communication/SMV."); - - if (string.IsNullOrWhiteSpace(stream.SvId)) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no svID/smvID."); - - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no resolved DataSet entries."); - - var noAsdu = ResolveAsduPerFrame(stream); - if (noAsdu > MaxAsduPerFrame) - throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher supports up to {MaxAsduPerFrame} ASDUs per frame."); - - return new SampledValuesPublisherProfile(stream, stream.Address.AppId.Value, stream.Address.DestinationMac.Value, stream.Address.ToVlanTag()); - } - - public static ushort ResolveAsduPerFrame(SclSampledValuesStream stream) - { - ArgumentNullException.ThrowIfNull(stream); - return (stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu); - } - - public static double ResolvePublicationRate(double sampleRateHz, ushort noAsdu) - { - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - if (noAsdu == 0) - throw new ArgumentOutOfRangeException(nameof(noAsdu), "nofASDU must be greater than 0."); - - return sampleRateHz / noAsdu; - } - - private void ValidateAsduPayloadBatch(IReadOnlyList samplePayloads) - { - var expected = AsduPerFrame; - if (samplePayloads.Count != expected) - throw new ArgumentException($"SV {Stream.ControlBlockReference} expects nofASDU={expected}, got {samplePayloads.Count} payload(s).", nameof(samplePayloads)); - - for (var i = 0; i < samplePayloads.Count; i++) - { - if (samplePayloads[i].Length != PayloadLayout.PayloadByteLength) - throw new ArgumentException($"SV ASDU payload {i} has {samplePayloads[i].Length} byte(s), expected {PayloadLayout.PayloadByteLength}.", nameof(samplePayloads)); - } - } - - private static ushort? TryMapSampleMode(string sampleMode) - { - if (string.IsNullOrWhiteSpace(sampleMode)) - return null; - - return sampleMode.Trim() switch - { - "SmpPerPeriod" => 0, - "SmpPerSec" => 1, - "SecPerSmp" => 2, - _ => null - }; - } -} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs deleted file mode 100644 index c6a4356..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs +++ /dev/null @@ -1,100 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public enum SampledValuesPublisherValidationSeverity -{ - Info, - Warning, - Error -} - -public sealed record SampledValuesPublisherValidationFinding( - SampledValuesPublisherValidationSeverity Severity, - string Code, - string Message, - string Detail = ""); - -public sealed class SampledValuesPublisherValidationReport -{ - public SampledValuesPublisherValidationReport(IReadOnlyList findings) - { - Findings = findings; - } - - public IReadOnlyList Findings { get; } - public bool HasErrors => Findings.Any(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); - public int ErrorCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); - public int WarningCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Warning); - public int InfoCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Info); -} - -public static class SampledValuesPublisherValidator -{ - public static SampledValuesPublisherValidationReport Validate(SclSampledValuesStream stream) - { - ArgumentNullException.ThrowIfNull(stream); - var findings = new List(); - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_STREAM", stream.ControlBlockReference, $"svID={Text(stream.SvId)}, datSet={Text(stream.DataSetReference)}"); - - if (!stream.Address.AppId.HasValue) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_APPID_MISSING", "APPID is missing.", "Add Communication/SubNetwork/ConnectedAP/SMV/Address/P type=APPID."); - else if (stream.Address.AppId.Value == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_APPID_ZERO", "APPID is 0x0000.", "Use the APPID expected by the subscriber unless this is intentional."); - - if (!stream.Address.DestinationMac.HasValue) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_MISSING", "Destination MAC is missing or invalid.", stream.Address.DestinationMacText); - else - ValidateDestinationMac(stream.Address.DestinationMac.Value, findings); - - if (string.IsNullOrWhiteSpace(stream.SvId)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_ID_MISSING", "svID/smvID is missing."); - - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DATASET_MISSING", "DataSet cannot be resolved.", stream.DataSetName); - - if (stream.ConfigurationRevision == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_CONFREV_ZERO", "confRev is 0.", "Most engineering files use a positive configuration revision."); - - if (stream.SampleRate == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_SMPRATE_MISSING", "smpRate is missing or 0.", "The operator must select an explicit sample rate in ARSVIN."); - - var noAsdu = (stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu); - if (noAsdu > SampledValuesPublisherProfile.MaxAsduPerFrame) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_NOFASDU_UNSUPPORTED", $"nofASDU={noAsdu} is above the supported limit.", $"This publisher supports 1..{SampledValuesPublisherProfile.MaxAsduPerFrame} ASDU(s) per frame."); - else if (noAsdu > 1) - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_NOFASDU_PACKING", $"nofASDU={noAsdu} ASDU(s) per Ethernet frame.", "The publisher will pack sequential samples into one SavPdu."); - - var layout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); - if (!layout.IsFullySupported) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_PAYLOAD_UNSUPPORTED", "Unsupported SV payload layout.", string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}"))); - else - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_PAYLOAD_LAYOUT", "Payload layout supported.", $"entries={stream.Entries.Count}, payload={layout.PayloadByteLength} bytes per ASDU."); - - return new SampledValuesPublisherValidationReport(findings); - } - - private static void ValidateDestinationMac(MacAddress mac, List findings) - { - var bytes = mac.ToArray(); - if (bytes.All(value => value == 0)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_ZERO", "Destination MAC is all zeros.", mac.ToString()); - else if (bytes.All(value => value == 0xFF)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_BROADCAST", "Destination MAC should not be broadcast for SV.", mac.ToString()); - else if ((bytes[0] & 0x01) == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_UNICAST", "Destination MAC is not multicast.", mac.ToString()); - else if (!(bytes[0] == 0x01 && bytes[1] == 0x0C && bytes[2] == 0xCD && bytes[3] == 0x04)) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_RANGE", "Destination MAC is multicast but outside the common SV multicast range.", mac.ToString()); - } - - private static void Add( - List findings, - SampledValuesPublisherValidationSeverity severity, - string code, - string message, - string detail = "") - => findings.Add(new SampledValuesPublisherValidationFinding(severity, code, message, detail)); - - private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "-" : value; -} diff --git a/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj b/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj index 67ad17e..1ebc917 100644 --- a/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj +++ b/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj @@ -17,7 +17,8 @@ - + + diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml.cs b/src/ARSVIN.Subscriber/MainWindow.xaml.cs index 0a42d46..0a3398d 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml.cs +++ b/src/ARSVIN.Subscriber/MainWindow.xaml.cs @@ -25,7 +25,11 @@ public MainWindow() DataContext = _viewModel; _viewModel.Streams.CollectionChanged += Streams_CollectionChanged; AttachLivePlotControls(); - Loaded += (_, _) => AttachMeasurementContextToolbar(); + Loaded += (_, _) => + { + AttachMeasurementContextToolbar(); + AttachGenericExplorerUi(); + }; } private void AttachLivePlotControls() @@ -34,17 +38,85 @@ private void AttachLivePlotControls() BindingOperations.SetBinding( scope, OscilloscopePlot.PointsProperty, - new Binding("SelectedStream.WaveformPoints")); + new Binding("SelectedStream.GenericWaveformPoints")); ScopeHost.Child = scope; var phasor = new PhasorPlot(); BindingOperations.SetBinding( phasor, PhasorPlot.VectorsProperty, - new Binding("SelectedStream.Phasors")); + new Binding("SelectedStream.GenericPhasors")); PhasorHost.Child = phasor; } + private void AttachGenericExplorerUi() + { + foreach (var text in FindVisualChildren(this)) + { + if (string.Equals(text.Text, "PROFILE", StringComparison.Ordinal)) + text.Text = "MAPPING"; + else if (string.Equals(text.Text, "CONFIDENCE", StringComparison.Ordinal)) + text.Text = "SEMANTICS"; + + var binding = BindingOperations.GetBinding(text, TextBlock.TextProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Profile", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericMappingState")); + } + else if (string.Equals(path, "SelectedStream.Confidence", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericSemanticState")); + } + else if (string.Equals(path, "SelectedStream.WaveformState", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericWaveformState")); + } + } + + foreach (var grid in FindVisualChildren(this)) + { + var binding = BindingOperations.GetBinding(grid, ItemsControl.ItemsSourceProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Phasors", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericPhasors")); + } + else if (string.Equals(path, "SelectedValues", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericValues")); + UpdateGenericDecodedColumns(grid); + } + } + } + + private static void UpdateGenericDecodedColumns(DataGrid grid) + { + if (grid.Columns.Count < 6) + return; + + grid.Columns[1].Header = "Word / SCL signal"; + grid.Columns[2].Header = "Representation"; + grid.Columns[3].Header = "Value"; + grid.Columns[4].Header = "Semantics"; + grid.Columns[5].Header = "Raw bytes"; + } + private void AttachMeasurementContextToolbar() { var toolbar = FindVisualChildren(this) diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs new file mode 100644 index 0000000..689f1ad --- /dev/null +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs @@ -0,0 +1,146 @@ +using System.Buffers.Binary; +using System.Collections.Specialized; +using System.ComponentModel; +using AR.Iec61850.SampledValues.Measurements; +using ARSVIN.Subscriber.Models; + +namespace ARSVIN.Subscriber.ViewModels; + +public sealed partial class SvStreamViewModel +{ + private readonly BulkObservableCollection _genericValues = new(); + private readonly BulkObservableCollection _genericWaveformPoints = new(); + private readonly BulkObservableCollection _genericPhasors = new(); + private string _genericMappingState = "Raw seqOfData"; + private string _genericSemanticState = "Unresolved · no assumptions"; + private string _genericWaveformState = "Waiting for stream data"; + + public SvStreamViewModel() + { + _values.CollectionChanged += SourceCollectionChanged; + _waveformPoints.CollectionChanged += SourceCollectionChanged; + _phasors.CollectionChanged += SourceCollectionChanged; + PropertyChanged += StreamPropertyChanged; + RefreshGenericPresentation(); + } + + public IReadOnlyList GenericValues => _genericValues; + public IReadOnlyList GenericWaveformPoints => _genericWaveformPoints; + public IReadOnlyList GenericPhasors => _genericPhasors; + + public string GenericMappingState + { + get => _genericMappingState; + private set => SetProperty(ref _genericMappingState, value); + } + + public string GenericSemanticState + { + get => _genericSemanticState; + private set => SetProperty(ref _genericSemanticState, value); + } + + public string GenericWaveformState + { + get => _genericWaveformState; + private set => SetProperty(ref _genericWaveformState, value); + } + + private void SourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => RefreshGenericPresentation(); + + private void StreamPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(Bound) or nameof(WaveformState) or nameof(Scaling)) + RefreshGenericPresentation(); + } + + private void RefreshGenericPresentation() + { + if (HasSclSemanticMapping()) + { + GenericMappingState = "SCL dataset mapping"; + GenericSemanticState = "Resolved from ordered SCL elements"; + GenericWaveformState = WaveformState; + _genericValues.ReplaceAll(_values); + _genericWaveformPoints.ReplaceAll(_waveformPoints); + _genericPhasors.ReplaceAll(_phasors); + return; + } + + GenericMappingState = "Raw seqOfData"; + GenericSemanticState = "Unresolved · words shown without channel, unit, or quality claims"; + GenericWaveformState = _values.Count == 0 + ? "Waiting for seqOfData" + : "Raw words available · import SCL before semantic waveform and phasor analysis"; + _genericValues.ReplaceAll(BuildGenericRows(_values)); + _genericWaveformPoints.ReplaceAll(Array.Empty()); + _genericPhasors.ReplaceAll(Array.Empty()); + } + + private bool HasSclSemanticMapping() + => Bound.StartsWith("SCL:", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList BuildGenericRows(IReadOnlyList source) + { + if (source.Count == 0) + return Array.Empty(); + + var rows = new List(source.Count); + for (var index = 0; index < source.Count; index++) + { + var original = source[index]; + var byteOffset = index * 4; + if (!TryReadWord(original.Raw, out var signed, out var unsigned)) + { + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Bytes {index + 1} (+0x{byteOffset:X2})", + Kind = "Raw bytes", + Value = original.Raw, + Raw = original.Raw, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "No SCL mapping is bound; bytes are preserved without semantic interpretation." + }); + continue; + } + + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Word {index + 1} (+0x{byteOffset:X2})", + Kind = index % 2 == 0 ? "INT32 / UINT32 · group word 1" : "INT32 / UINT32 · group word 2", + Value = $"{signed} / {unsigned}", + Raw = original.Raw, + NumericValue = signed, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "Generic 32-bit representation only. Channel, quality, unit, and scaling semantics are unresolved until SCL or explicit reviewed mapping is available." + }); + } + + return rows; + } + + private static bool TryReadWord(string rawHex, out int signed, out uint unsigned) + { + signed = 0; + unsigned = 0; + if (string.IsNullOrWhiteSpace(rawHex) || rawHex.Length != 8) + return false; + + try + { + var bytes = Convert.FromHexString(rawHex); + unsigned = BinaryPrimitives.ReadUInt32BigEndian(bytes); + signed = unchecked((int)unsigned); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/ARSVIN/ARSVIN.csproj b/src/ARSVIN/ARSVIN.csproj index a5f0d17..3d16457 100644 --- a/src/ARSVIN/ARSVIN.csproj +++ b/src/ARSVIN/ARSVIN.csproj @@ -17,7 +17,8 @@ - + + diff --git a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj index e39f9f9..9c8b8e1 100644 --- a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj +++ b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj @@ -22,7 +22,8 @@ - + + diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs new file mode 100644 index 0000000..988fa45 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericAsduInspectorTests +{ + [Fact] + public void InspectKeepsWireFieldsSeparateFromDatasetSemantics() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + DataSetReference = "LD0/LLN0$Dataset1", + SampleCount = 123, + ConfigurationRevision = 7, + SampleSynchronization = 2, + SampleRate = 4_800, + SampleMode = 1, + SamplePayload = new byte[12] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Equal("MU01", inspection.SvId); + Assert.Equal((ushort)123, inspection.SampleCount); + Assert.Equal((uint)7, inspection.ConfigurationRevision); + Assert.Equal((ushort)4_800, inspection.SampleRate); + Assert.Equal((ushort)1, inspection.SampleMode); + Assert.Equal(3, inspection.Payload.CompleteWordCount); + Assert.Contains("bind SCL", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Contains("smpRate", inspection.OptionalFieldSummary); + Assert.Contains("smpMod", inspection.OptionalFieldSummary); + } + + [Fact] + public void InspectReportsUnresolvedMappingWhenDatasetReferenceIsAbsent() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + SamplePayload = new byte[8] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Contains("unresolved", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Equal("No optional ASDU fields observed", inspection.OptionalFieldSummary); + } +} diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs new file mode 100644 index 0000000..55eba76 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericPayloadInspectorTests +{ + [Fact] + public void InspectExposesEveryWordWithoutInventingChannelSemantics() + { + var payload = new byte[64]; + payload[2] = 0x03; + payload[3] = 0xE8; + payload[8] = 0xFF; + payload[9] = 0xFF; + payload[10] = 0xFC; + payload[11] = 0x18; + + var inspection = SvGenericPayloadInspector.Inspect(payload); + + Assert.Equal(64, inspection.PayloadLength); + Assert.Equal(16, inspection.CompleteWordCount); + Assert.True(inspection.IsFourByteAligned); + Assert.True(inspection.HasEightByteGroupShape); + Assert.Equal(1000, inspection.Words[0].SignedInt32); + Assert.Equal(-1000, inspection.Words[2].SignedInt32); + Assert.All(inspection.Words, word => Assert.StartsWith("Word ", word.GenericLabel)); + Assert.DoesNotContain(inspection.Words, word => + word.GenericLabel.Contains("Ia", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Voltage", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Quality", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectUsesBigEndianSignedAndUnsignedViewsOfTheSameBytes() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0xFF, 0xFF, 0xFF, 0xFE }); + + var word = Assert.Single(inspection.Words); + Assert.Equal(-2, word.SignedInt32); + Assert.Equal(4_294_967_294u, word.UnsignedInt32); + Assert.Equal("FFFFFFFE", word.Hex); + Assert.Equal("+0x00", word.OffsetLabel); + Assert.Equal(SvGenericPayloadWordRole.StandaloneWord, word.StructuralRole); + } + + [Fact] + public void InspectMarksEightByteGroupingAsStructuralOnly() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[16]); + + Assert.Equal(SvGenericPayloadWordRole.FirstWordInEightByteGroup, inspection.Words[0].StructuralRole); + Assert.Equal(SvGenericPayloadWordRole.SecondWordInEightByteGroup, inspection.Words[1].StructuralRole); + Assert.Contains(inspection.Diagnostics, diagnostic => + diagnostic.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectPreservesTrailingBytesInsteadOfDroppingPayload() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0x00, 0x00, 0x00, 0x01, 0xAA, 0xBB }); + + Assert.False(inspection.IsFourByteAligned); + Assert.Equal(1, inspection.CompleteWordCount); + Assert.Equal(2, inspection.TrailingByteCount); + Assert.Equal(new byte[] { 0xAA, 0xBB }, inspection.TrailingBytes); + Assert.Contains("2 trailing byte(s)", inspection.Summary); + } + + [Fact] + public void InspectHandlesEmptyPayloadExplicitly() + { + var inspection = SvGenericPayloadInspector.Inspect(ReadOnlyMemory.Empty); + + Assert.Empty(inspection.Words); + Assert.Equal("Empty seqOfData payload", inspection.Summary); + Assert.Contains("empty", Assert.Single(inspection.Diagnostics), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs index d120bbb..a09634c 100644 --- a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs @@ -6,7 +6,7 @@ namespace ARSVIN.Tests; public sealed class SampledValuesMeasurementsTests { [Fact] - public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() + public void ScaleResolverDoesNotInferCurrentUnitsWithoutSclMapping() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -19,14 +19,15 @@ public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() DeclaredSampleRate = 4_000 }); - Assert.Equal(SvEngineeringScaleSource.Legacy92LeStyleStructuralInference, scale.Source); - Assert.Equal(SvEngineeringScaleConfidence.Inferred, scale.Confidence); - Assert.Equal("A", scale.Unit); - Assert.Equal(1.0, scale.Apply(1_000), 9); + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal(SvEngineeringScaleConfidence.Unknown, scale.Confidence); + Assert.Equal("count", scale.Unit); + Assert.Equal(1_000, scale.Apply(1_000)); + Assert.Contains("No SCL dataset mapping", scale.Reason, StringComparison.OrdinalIgnoreCase); } [Fact] - public void ScaleResolverConvertsFixedProtectionVoltageToVolts() + public void ScaleResolverConvertsSclMappedFixedProtectionVoltageToVolts() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -35,7 +36,9 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() IsSclBound = true, IsFixedFourCurrentFourVoltageLayout = true, AnalogChannelCount = 8, - PayloadBytesPerAsdu = 64 + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4_000 }); Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); @@ -44,6 +47,23 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() Assert.Equal(100.0, scale.Apply(10_000), 9); } + [Fact] + public void ScaleResolverWithholdsFixedLayoutScalingWhenRateEvidenceIsMissing() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Contains("rate evidence", scale.Reason, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { @@ -51,6 +71,7 @@ public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { Channel = "Ia", Kind = "Current", + IsSclBound = true, AnalogChannelCount = 12, PayloadBytesPerAsdu = 96, ObservedSamplesPerSecond = 4_000 diff --git a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs index 831dbc1..ca2ccd8 100644 --- a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs @@ -19,8 +19,8 @@ public void QualityDecoderRecognizesAllZeroQualityAsGood() [Fact] public void QualityDecoderRecognizesInvalidFailureAsBad() { - // Validity bits 10b = Invalid, plus Failure bit 6. - var state = SvQualityDecoder.DecodeWord(0x0042); + // IEC 61850 validity bits 01b = Invalid, plus Failure bit 6. + var state = SvQualityDecoder.DecodeWord(0x0041); Assert.Equal(SvQualityValidity.Invalid, state.Validity); Assert.True(state.Failure);