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);