diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 530c651..58d1037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,34 @@ permissions: contents: read jobs: + public-content: + name: Validate public terminology + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Validate public terminology neutrality + id: neutrality + continue-on-error: true + run: python3 scripts/validate-public-neutrality.py + + - name: Upload terminology validation evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: arsvin-public-neutrality-report + path: artifacts/public-neutrality-report.txt + if-no-files-found: error + retention-days: 14 + + - name: Enforce terminology validation result + if: steps.neutrality.outcome == 'failure' + run: exit 1 + build-test: name: Build, test, and validate site + needs: public-content runs-on: windows-2025 steps: - name: Checkout @@ -56,7 +82,7 @@ jobs: - name: Test with whole-engine and protocol-core coverage gates shell: pwsh - run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60 -NoRestore + run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 -NoRestore - name: Upload test and coverage evidence if: always() diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2b1de49..fdcaba5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -9,6 +9,7 @@ on: - 'scripts/build-public-site.py' - 'scripts/validate-public-site.ps1' - 'scripts/validate-public-site.py' + - 'scripts/validate-public-neutrality.py' - '.github/workflows/pages.yml' workflow_dispatch: @@ -31,6 +32,9 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: Validate public terminology neutrality + run: python3 scripts/validate-public-neutrality.py + - name: Build public site and HTML documentation run: python3 scripts/build-public-site.py --output artifacts/public-site diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ef5bc..536ed4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,25 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change ### Added -- Added a formal Sampled Values standards and evidence research gate covering authoritative source hierarchy, claim levels, OMICRON benchmark findings, licensed-standard gaps, and implementation acceptance rules. +- Added a formal Sampled Values standards and evidence research gate covering authoritative source hierarchy, claim levels, licensed-standard gaps, and implementation acceptance rules. - Added a conformance and interoperability matrix separating verified, implemented-generic, provisional, unknown, and out-of-scope behavior for Layer-2 SV, 9-2LE-style workflows, IEC 61869-9, timing, scaling, redundancy, and product claims. - Added a safe Sampled Values evidence-intake guide for anonymized SCL/PCAP cases, real-device metadata, independent verification, minimum negative tests, and evidence quality levels. +- Added transport-independent SV frame observations and an accumulator for stable-field checks, rate estimation, and sample-counter wrap detection. +- Added evidence-aware profile definitions with standards-neutral sampling, dataset, packing, payload, and source metadata. +- Added an explainable weighted profile detector that reports matches, conflicts, missing evidence, confidence, and score. +- Added strict and compatible configuration-versus-wire comparison findings without blocking capture or decoding. +- Added deterministic tests for observation windows, profile detection, evidence explanations, configuration mismatch handling, sparse-evidence confidence, and profile-definition provenance. +- Added public terminology-neutrality validation to CI and Pages deployment with retained validation evidence. ### Changed - Documentation now prevents unverified profile constants from being promoted directly into production support claims. -- The SV roadmap now treats StationScout as a system-engineering workflow benchmark and DANEO 400 as the stronger benchmark for SCL-versus-wire verification, merging-unit analysis, timing statistics, orphan detection, triggered recording, and evidence reporting. +- Public engineering workflow targets are described generically without proprietary product comparisons or branding. +- The built-in profile catalog contains only the generic SCL-driven Layer-2 SV fallback until profile-specific requirements are verified. +- Sparse evidence can no longer produce a `Confirmed` profile result; confirmation requires sufficient evaluated evidence plus matching dataset and sampling behavior. +- Expanded the deterministic suite from 54 to 74 tests. +- Raised whole-engine coverage from 10.74% to 13.35% and the enforced floor from 10.5% to 13%. +- Raised protocol-core coverage from 64.97% to 70.47% and the enforced floor from 60% to 70%. ## 0.4.0 — 2026-07-12 ### Added diff --git a/README.md b/README.md index 689ba81..9b7c745 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,23 @@ The tools are intentionally separated. Publisher evidence proves what the PC gen - `smpCnt` continuity and stream-health diagnostics. - Decoded instantaneous values, oscilloscope waveform, phasor, and RMS views. - Receiver-side Markdown evidence reports. +- Shared-engine foundation for evidence-aware profile classification and configuration-versus-wire comparison; live UI integration remains a following milestone. + +## Profile intelligence foundation + +The shared engine now separates: + +```text +Observed wire facts +Configured SCL expectations +Evidence-backed profile definitions +Explainable profile confidence +Strict or compatible mismatch findings +``` + +Sparse evidence cannot produce a false `Confirmed` result. Unknown or conflicting receive traffic remains observable, and the built-in catalog contains only a generic SCL-driven Layer-2 fallback until named-profile requirements are verified. + +See [SV Profile Infrastructure](docs/sv-profile-infrastructure.md) and [SV Standards and Evidence Research Gate](docs/sv-research-gate.md). ## Release downloads @@ -108,9 +125,11 @@ See [Quick Start](docs/quick-start.md) and [Build and Release](docs/build-and-re | Area | Current status | Boundary | |---|---|---| -| IEC 61850 Sampled Values APDU | Publisher and subscriber implementation for engineering/lab use | Not certified conformance testing. | -| IEC 61850-9-2LE-style 4I+4V | Supported lab profile | Validate formal requirements independently. | -| `nofASDU` | `1`, `2`, `4`, `8` | Publisher and receiver behavior is software/PC timing dependent. | +| IEC 61850 Sampled Values APDU | Publisher and Subscriber implementation for engineering/lab use | Not certified conformance testing. | +| IEC 61850-9-2LE-style 4I+4V | Implemented laboratory workflow | Formal profile verification and broader device evidence remain pending. | +| Generic SCL-driven Layer-2 SV | Dataset-aware engine foundation | Unknown layouts remain visible; unsupported payload elements are diagnosed. | +| Evidence-aware profile detection | Engine infrastructure implemented | Named-profile definitions await verified source and device evidence. | +| `nofASDU` | UI workflows emphasize `1`, `2`, `4`, `8` | Publisher and receiver behavior is software/PC timing dependent. | | COMTRADE | ASCII, BINARY, BINARY32, FLOAT32 analog replay | Verify scaling and channel mapping before live TX. | | PTP / `smpSynch` | Compatibility and lab behavior | Not an IEC 61850-9-3 certified clock. | | Windows timing | Best-effort scheduling with visible health metrics | Not deterministic real-time execution. | @@ -123,7 +142,7 @@ For users: - Windows 10 or Windows 11, x64. - Npcap for live capture or transmission. - Administrator permission when required by the local Npcap/network configuration. -- Wireshark or another independent packet tool is recommended for verification. +- An independent packet dissector or process-bus analyzer is recommended for verification. For developers: @@ -145,27 +164,27 @@ NuGet versions are centrally managed and committed `packages.lock.json` files lo Build all release artifacts except the installer: ```powershell -.\scripts\publish-release.ps1 -Version 0.3.1 +.\scripts\publish-release.ps1 -Version 0.4.0 ``` Compatibility wrapper: ```powershell -.\publish-win-x64.ps1 -Version 0.3.1 +.\publish-win-x64.ps1 -Version 0.4.0 ``` Run tests with both repository coverage gates and retain TRX/Cobertura evidence: ```powershell -.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60 +.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 ``` -The current suite contains 54 deterministic tests. The complete shared `ARSVIN.Engine` baseline measures 10.74% line coverage across 15,742 instrumented production lines, with 1,691 covered lines. The protocol-core regression surface measures 64.97% across 1,550 lines, with 1,007 covered lines. CI enforces floors of 10.5% for the whole engine and 60% for protocol core. +The current suite contains 74 deterministic tests. The complete shared `ARSVIN.Engine` baseline measures 13.35% line coverage across 16,312 instrumented production lines, with 2,178 covered lines. The protocol-core regression surface measures 70.47% across 2,120 lines, with 1,494 covered lines. CI enforces floors of 13% for the whole engine and 70% for protocol core. Generate a CycloneDX SBOM after restoring the solution: ```powershell -.\scripts\generate-sbom.ps1 -Version 0.3.1 +.\scripts\generate-sbom.ps1 -Version 0.4.0 ``` ## Repository structure @@ -173,6 +192,7 @@ Generate a CycloneDX SBOM after restoring the solution: ```text src/ARSVIN.Engine/ Shared production engine project and source ownership src/ARSVIN.Engine/AR.Iec61850/ IEC 61850, SCL, SV, MMS, capture, diagnostics, and protocol code +src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/ Profile observation, detection, and comparison engine src/ARSVIN.Engine/AR.Iec61850.Transports.Npcap/ Npcap transport implementation src/ARSVIN/ Publisher application src/ARSVIN.Subscriber/ ArSubsv subscriber and visualization companion @@ -191,6 +211,10 @@ The shared engine is compiled once as `ARSVIN.Engine`, and its source is physica - [Documentation index](docs/index.md) - [Quick start](docs/quick-start.md) +- [SV standards and evidence research gate](docs/sv-research-gate.md) +- [SV conformance and interoperability matrix](docs/sv-evidence-matrix.md) +- [SV profile infrastructure](docs/sv-profile-infrastructure.md) +- [Profile detection output contract](docs/profile-detection-output.md) - [Subscriber verification app](docs/subscriber-verification-app.md) - [ArSubsv SV scout companion](docs/arsubsv-sv-scout-companion.md) - [SV profile support](docs/sv-profile-support.md) diff --git a/docs/arsubsv-sv-scout-companion.md b/docs/arsubsv-sv-scout-companion.md index 33e6d31..23f3810 100644 --- a/docs/arsubsv-sv-scout-companion.md +++ b/docs/arsubsv-sv-scout-companion.md @@ -2,7 +2,7 @@ ArSubsv is the receiver-side companion application for ARSVIN. It is designed to make IEC 61850 Sampled Values visible during lab publishing, commissioning preparation, and publisher verification. -ArSubsv is not an OMICRON product and does not copy OMICRON branding or UI. Its feature target is the same engineering class: discover SV streams, subscribe to one or more streams, visualize current/voltage waveforms, calculate RMS/phase indicators, compare stream timing and counters, and export receiver-side evidence. +ArSubsv uses its own branding and interface. Its engineering purpose is to discover SV streams, subscribe to one or more streams, visualize current and voltage waveforms, calculate RMS and phase indicators, compare stream timing and counters, and export receiver-side evidence. ## Feature set @@ -21,7 +21,7 @@ ArSubsv is not an OMICRON product and does not copy OMICRON branding or UI. Its ArSubsv verifies that this PC and selected NIC receive and decode Sampled Values. It cannot prove that another relay, protection IED, BCU, or fault recorder has consumed the same multicast stream. -SCL binding improves decoding and validation quality. Without SCL, ArSubsv can still discover and count streams, but waveform/phasor/value visualization is limited because the payload layout is not known. +SCL binding improves decoding and validation quality. Without SCL, ArSubsv can still discover and count streams, but waveform, phasor, and value visualization is limited because the payload layout is not known. ## Capture file support diff --git a/docs/build-and-release.md b/docs/build-and-release.md index c81189a..c19b17e 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -19,7 +19,7 @@ Npcap is required only for live capture/transmission testing. It is not silently ## Dependency locking -Package versions are centrally managed in `Directory.Packages.props`. Each project also commits a `packages.lock.json` file so the resolved direct and transitive NuGet graph is reviewable and repeatable. +Package versions are centrally managed in `Directory.Packages.props`. Each project commits a `packages.lock.json` file so the resolved direct and transitive NuGet graph is reviewable and repeatable. Validated automation restores with locked mode: @@ -48,14 +48,14 @@ The script restores the solution in locked mode, then builds and tests: - `src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj` - `tests/ARSVIN.Tests/ARSVIN.Tests.csproj` -The shared production source is physically owned by `src/ARSVIN.Engine`, including `AR.Iec61850` and the Npcap transport implementation. Publisher, Subscriber, and Tests all reference that same compiled assembly. +The shared production source is physically owned by `src/ARSVIN.Engine`, including IEC 61850 protocol code, Sampled Values profile infrastructure, and the Npcap transport implementation. Publisher, Subscriber, and Tests reference the same compiled assembly. External command exit codes are checked, and compiler warnings are treated as errors for the validated build path. ### Coverage evidence ```powershell -.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 10.5 -MinimumLineCoverage 60 +.\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 13 -MinimumLineCoverage 70 ``` The script: @@ -64,23 +64,37 @@ The script: 2. instruments the complete shared production `ARSVIN.Engine` assembly, 3. writes TRX, the complete `dotnet test` log, and Cobertura evidence under `artifacts/test-results`, 4. calculates and enforces the whole-engine regression floor, -5. calculates and enforces the established protocol-core regression floor, +5. calculates and enforces the protocol-core regression floor, 6. fails when no production lines are instrumented or either configured floor is missed. -Current verified baselines from 54 deterministic tests: +Current verified baselines from 74 deterministic tests: | Metric | Result | |---|---:| -| Whole `ARSVIN.Engine` instrumented lines | 15,742 | -| Whole-engine covered lines | 1,691 | -| Whole-engine line coverage | 10.74% | -| Enforced whole-engine floor | 10.5% | -| Protocol-core instrumented lines | 1,550 | -| Protocol-core covered lines | 1,007 | -| Protocol-core line coverage | 64.97% | -| Enforced protocol-core floor | 60% | +| Whole `ARSVIN.Engine` instrumented lines | 16,312 | +| Whole-engine covered lines | 2,178 | +| Whole-engine line coverage | 13.35% | +| Enforced whole-engine floor | 13% | +| Protocol-core instrumented lines | 2,120 | +| Protocol-core covered lines | 1,494 | +| Protocol-core line coverage | 70.47% | +| Enforced protocol-core floor | 70% | -The expanded suite covers deterministic behavior across Sampled Values, SCL parsing, COMTRADE parsing and scaling, PCAP read/write handling, MMS data and object references, diagnostics, Ethernet framing, transport helpers, and publisher session behavior. This is not a claim that the complete WPF UI or every live-network path is covered; live Npcap, timing, and device-interoperability behavior still require controlled laboratory validation. +The suite covers deterministic behavior across Sampled Values framing, publisher sessions, profile observations, evidence-aware detection, configuration comparison, SCL parsing, COMTRADE parsing and scaling, PCAP read/write handling, MMS data and object references, diagnostics, Ethernet framing, and transport helpers. It does not claim complete WPF, live-network, timing, or device-interoperability coverage. + +## Validate public content + +Public documentation uses neutral engineering terminology and avoids proprietary comparison language. + +```powershell +python scripts/validate-public-neutrality.py +``` + +CI and Pages deployment run this check before publishing public content. The validator writes evidence to: + +```text +artifacts/public-neutrality-report.txt +``` ## Validate the public site @@ -109,18 +123,18 @@ The validator recursively checks: - release filenames, - sitemap metadata in `robots.txt`. -The same builder and validator run directly inside GitHub Pages deployment. A broken page cannot deploy merely because a separate CI job has not finished yet. +The same builder and validator run inside GitHub Pages deployment. A broken page cannot deploy merely because another CI job has not finished. ## Build portable release artifacts ```powershell -.\scripts\publish-release.ps1 -Version 0.3.1 +.\scripts\publish-release.ps1 -Version 0.4.0 ``` Compatibility wrapper: ```powershell -.\publish-win-x64.ps1 -Version 0.3.1 +.\publish-win-x64.ps1 -Version 0.4.0 ``` Generated files: @@ -145,7 +159,7 @@ The two direct `.exe` files are self-contained .NET 8 single-file applications. After restoring the solution, run: ```powershell -.\scripts\generate-sbom.ps1 -Version 0.3.1 +.\scripts\generate-sbom.ps1 -Version 0.4.0 ``` Output: @@ -154,16 +168,16 @@ Output: artifacts/release/ARSVIN-SBOM.cdx.json ``` -The generator reads the locked, resolved NuGet graphs for Publisher and Subscriber, deduplicates direct and transitive application packages, records which application uses each package, and writes CycloneDX 1.5 JSON. Test-only packages such as xUnit and Coverlet are intentionally excluded. +The generator reads the locked, resolved NuGet graphs for Publisher and Subscriber, deduplicates direct and transitive application packages, records which application uses each package, and writes CycloneDX 1.5 JSON. Test-only packages are excluded. -The SBOM includes a deterministic UUID URN `serialNumber`, source commit metadata, component version, and stable component ordering. Repeated generation from the same commit and version is reviewable. The SBOM covers managed application dependencies; it does not claim to inventory Windows, Npcap, GitHub-hosted runner contents, or every build-service tool. +The SBOM includes a deterministic UUID URN `serialNumber`, source commit metadata, component version, and stable component ordering. It covers managed application dependencies; it does not claim to inventory Windows, Npcap, hosted-runner contents, or every build-service tool. ## Build the installer locally After running the publish script: ```powershell -$version = '0.3.1' +$version = '0.4.0' $sourceDir = (Resolve-Path '.\artifacts\installer-input').Path $outputDir = (Resolve-Path '.\artifacts\release').Path $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" @@ -181,7 +195,7 @@ Output: artifacts/release/ARSVIN-Suite-Setup-win-x64.exe ``` -The automated workflow installs and verifies the exact Chocolatey package version declared in `INNO_SETUP_VERSION`. It records the resolved `ISCC.exe` path and file metadata for evidence, while the exact package version remains the authoritative toolchain pin. +The automated workflow installs and verifies the exact package version declared in `INNO_SETUP_VERSION`. It records the compiler path and file metadata for evidence. The installer: @@ -200,16 +214,16 @@ Workflow: `.github/workflows/release.yml` ### Pull-request validation -When release tooling, installer definitions, project files, dependency locks, or build configuration change, the workflow runs on the pull request and: +When release tooling, installer definitions, project files, dependency locks, or build configuration change, the workflow: 1. restores the locked dependency graph, -2. builds and tests the solution with warnings treated as errors, -3. publishes both portable single-file applications, +2. builds and tests with warnings treated as errors, +3. publishes both portable applications, 4. creates the portable suite ZIP, -5. installs and verifies pinned Inno Setup, +5. installs and verifies pinned installer tooling, 6. compiles the installer, -7. generates and structurally validates the application-only CycloneDX SBOM, -8. checks all expected artifact names and non-empty files, +7. generates and validates the CycloneDX SBOM, +8. checks expected artifact names and non-empty files, 9. silently installs the suite into a temporary directory, 10. verifies Publisher, Subscriber, documentation, version file, and uninstaller, 11. silently uninstalls the temporary installation, @@ -219,7 +233,7 @@ A pull-request run never creates a public GitHub Release or public attestation. ### Stable tagged release -A public release is created only by pushing a semantic-version tag. The tagged commit must already be contained in `main`; the workflow rejects a tag created from an unmerged feature or release branch. +A public release is created only by pushing a semantic-version tag. The tagged commit must already be contained in `main`. ```powershell git switch main @@ -230,28 +244,24 @@ git push origin v0.4.0 The workflow repeats the validated packaging path, downloads the validated artifact in a separate least-privilege release job, verifies that no GitHub Release already exists for the tag, creates signed provenance and SBOM attestations, and publishes the public files. -Published GitHub Releases are immutable in automation. A rerun cannot replace existing assets. Any artifact correction requires a new patch version, such as `v0.4.1`. - -Stable tags are eligible to become the repository's latest release. +Published GitHub Releases are immutable in automation. Any artifact correction requires a new patch version. ### Prerelease tag -Semantic versions containing a suffix are published as GitHub prereleases and do not replace the latest stable release: +Semantic versions containing a suffix are published as prereleases and do not replace the latest stable release: ```powershell git switch main git pull --ff-only origin main -git tag -a v0.4.0-rc.1 -m "ARSVIN v0.4.0-rc.1" -git push origin v0.4.0-rc.1 +git tag -a v0.5.0-rc.1 -m "ARSVIN v0.5.0-rc.1" +git push origin v0.5.0-rc.1 ``` -Examples recognized as prereleases include `-alpha.1`, `-beta.1`, and `-rc.1`. - ### Manual artifact build -Run **Build Windows Release** from the Actions tab and provide a version such as `0.4.0-dev.1`. +Run **Build Windows Release** from the Actions tab and provide a version such as `0.5.0-dev.1`. -Manual runs upload private workflow artifacts only. They never create or replace a public GitHub Release, even when started while viewing another branch. +Manual runs upload private workflow artifacts only. They never create or replace a public GitHub Release. ## Release assets @@ -272,21 +282,21 @@ Verify the local hash: Get-FileHash .\ARSVIN-Suite-Setup-win-x64.exe -Algorithm SHA256 ``` -Compare the result with `SHA256SUMS.txt` from the same GitHub Release. +Compare the result with `SHA256SUMS.txt` from the same release. -Verify signed GitHub build provenance: +Verify signed build provenance: ```powershell gh attestation verify .\ARSVIN-Suite-Setup-win-x64.exe --repo masarray/arsvin ``` -GitHub artifact attestations use a short-lived signing identity issued during the tagged workflow. This validates repository/workflow provenance; it is separate from Windows Authenticode signing. +Artifact attestations validate repository/workflow provenance; they are separate from Windows Authenticode signing. ## Code signing status Release binaries are currently unsigned. Windows SmartScreen may display an unknown-publisher warning. The workflow intentionally does not contain placeholder signing steps or require paid signing secrets. -When a trusted code-signing certificate becomes available, sign the portable executables before compiling the installer, then sign the completed installer before checksum generation. +When a trusted certificate becomes available, sign the portable executables before compiling the installer, then sign the completed installer before checksum generation. ## Release checklist diff --git a/docs/implementation-gate-1-checklist.md b/docs/implementation-gate-1-checklist.md new file mode 100644 index 0000000..98ddd32 --- /dev/null +++ b/docs/implementation-gate-1-checklist.md @@ -0,0 +1,29 @@ +# Gate 1 — Profile Infrastructure Checklist + +## Completed + +- [x] Observed facts are independent of WPF and Npcap. +- [x] Observation windows calculate frame and sample rates. +- [x] Stable-field changes become diagnostics instead of false values. +- [x] Sample-counter wrap candidates are detected from observed sequences. +- [x] Profile definitions carry evidence status and source metadata. +- [x] Sampling basis supports samples per cycle, samples per second, and custom definitions. +- [x] Profile detection explains match, conflict, and unknown evidence. +- [x] Missing observations do not improve confidence. +- [x] Strict and compatible configuration comparison are available. +- [x] Unknown and conflicting receive traffic remains observable. +- [x] Built-in catalog contains no unverified named-profile constants. +- [x] Deterministic engine tests protect the infrastructure. +- [x] Active public documentation uses neutral engineering terminology. +- [x] CI and Pages validate public terminology neutrality. + +## Remaining integration + +- [ ] Feed live parsed frames into the observation accumulator. +- [ ] Feed PCAP-imported frames into the same observation accumulator. +- [ ] Convert selected SCL stream models into expected configurations. +- [ ] Add compact profile confidence state to Subscriber. +- [ ] Add expandable mismatch and evidence detail. +- [ ] Add report serialization for observed facts and detection evidence. + +The remaining items are integration work. They do not require profile-specific constants. diff --git a/docs/index.md b/docs/index.md index 0176ab5..f9cc9f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,11 @@ ARSVIN is a focused IEC 61850 Sampled Values engineering suite for Windows. It i ## Sampled Values standards and interoperability -- [SV Standards and Evidence Research Gate](sv-research-gate.md) — authoritative source hierarchy, OMICRON benchmark findings, claim levels, and research acceptance rules. +- [SV Standards and Evidence Research Gate](sv-research-gate.md) — authoritative source hierarchy, claim levels, evidence requirements, and research acceptance rules. - [SV Conformance and Interoperability Matrix](sv-evidence-matrix.md) — verified, provisional, unknown, and out-of-scope requirements for 9-2LE-style, IEC 61869-9, and generic Layer-2 SV. +- [SV Profile Infrastructure](sv-profile-infrastructure.md) — transport-independent observed facts, evidence-aware definitions, confidence detection, and configuration-versus-wire comparison. +- [Profile Detection Output Contract](profile-detection-output.md) — compact confidence, evidence, and mismatch presentation rules. +- [Public Terminology Policy](public-terminology-policy.md) — neutral public wording and automated validation. - `samples/sv-evidence` — safe evidence intake, anonymization rules, metadata, and minimum regression cases for real merging-unit captures. ## Publisher workflows @@ -51,4 +54,4 @@ ARSVIN is a focused IEC 61850 Sampled Values engineering suite for Windows. It i - `samples/comtrade` — COMTRADE replay files. - `samples/evidence` — sample Markdown evidence reports. - `samples/scenarios` — publisher scenario notes and matrices. -- `samples/sv-evidence` — anonymized or synthetic profile-validation evidence packages. \ No newline at end of file +- `samples/sv-evidence` — anonymized or synthetic profile-validation evidence packages. diff --git a/docs/p1-publisher-evidence-workflow.md b/docs/p1-publisher-evidence-workflow.md index 9e5b33c..f1acf8a 100644 --- a/docs/p1-publisher-evidence-workflow.md +++ b/docs/p1-publisher-evidence-workflow.md @@ -7,7 +7,7 @@ ARSVIN remains an IEC 61850 Sampled Values publisher. P1 adds evidence features - **Publisher evidence report**: exports a Markdown report containing the enabled publisher streams, SCL-derived identity, operator overrides, sample rate, publication rate, `nofASDU`, payload length, estimated bandwidth, quality mode, synchronization marking, and preflight findings. - **SCL validation evidence**: the report includes publisher-side SCL checks for APPID, destination MAC, svID, dataset, `confRev`, `smpRate`, `nofASDU`, and payload support. - **TX-side boundary statement**: the report explicitly states that it is publisher evidence only, not a capture analyzer and not conformance certification. -- **Recommended external verification**: the report tells the operator to export generated PCAP and inspect it in Wireshark or an external process-bus tool. +- **Recommended external verification**: the report tells the operator to export generated PCAP and inspect it with an independent packet dissector or process-bus analyzer. ## Intended usage @@ -21,7 +21,7 @@ ARSVIN remains an IEC 61850 Sampled Values publisher. P1 adds evidence features ## Scope boundary -P1 does not add a subscriber, network scanner, GOOSE/SV analyzer, or StationScout-like live mismatch engine. The report is generated from ARSVIN's configured TX plan and validation results. +P1 does not add a subscriber, network scanner, GOOSE/SV analyzer, or live configuration-versus-wire mismatch engine. The report is generated from ARSVIN's configured TX plan and validation results. ## Current limitations diff --git a/docs/profile-detection-output.md b/docs/profile-detection-output.md new file mode 100644 index 0000000..88a2be1 --- /dev/null +++ b/docs/profile-detection-output.md @@ -0,0 +1,47 @@ +# Profile Detection Output Contract + +The profile detector produces engineering evidence, not a conformance certificate. Subscriber UI and reports should present the same compact contract. + +## Required summary + +```text +Profile : +Confidence : Unknown | Possible | Likely | Confirmed | Conflict +Score : +Evidence : / +Conflicts : +Observation : +``` + +## Evidence detail + +Each evaluated field contains: + +- field name, +- outcome: match, conflict, or unknown, +- expected value, +- observed value, +- engineering explanation, +- evaluation weight. + +Unknown evidence is not counted as a match or conflict. Missing observations must not improve confidence. + +## UI behavior + +- Keep capture and decoding active for all confidence states. +- Show one compact state beside the selected stream. +- Put detailed evidence behind an expandable panel or report section. +- Use conflict state only when evaluated facts contradict a definition. +- Use unknown when there is insufficient evaluated evidence. +- Do not present a confirmed engineering classification as certification. + +## Configuration comparison + +Display configuration-versus-wire findings separately from profile classification: + +```text +Configured match : exact | warnings | errors +Mode : strict | compatible +``` + +Strict mode is suitable for validation and live-transmission preflight. Compatible mode is suitable for troubleshooting unfamiliar or partially documented traffic. diff --git a/docs/public-terminology-policy.md b/docs/public-terminology-policy.md new file mode 100644 index 0000000..1488b30 --- /dev/null +++ b/docs/public-terminology-policy.md @@ -0,0 +1,18 @@ +# Public Terminology Policy + +ARSVIN public documentation describes engineering outcomes, protocol behavior, standards references, and evidence requirements in neutral terms. + +## Public content rules + +- Do not use proprietary product comparisons as feature requirements. +- Do not imply compatibility, endorsement, certification, or equivalence with third-party products. +- Describe desired outcomes such as SCL-versus-wire comparison, orphan detection, stream-health analysis, triggered evidence capture, and repeatable reporting. +- Cite applicable standards publications and implementation guidelines without reproducing protected standard text. +- Keep manufacturer-specific observations inside authorized evidence records and publish only anonymized or synthetic regression fixtures. +- Record source provenance separately from product-facing language. + +## Automated validation + +CI and Pages deployment run `scripts/validate-public-neutrality.py` before building public documentation. The validator checks active public content and blocks prohibited comparison terminology. + +This validation protects the current repository tree. Historical Git commits remain part of normal repository history and should not be treated as current product documentation. diff --git a/docs/sv-evidence-matrix.md b/docs/sv-evidence-matrix.md index 33c3d06..562a229 100644 --- a/docs/sv-evidence-matrix.md +++ b/docs/sv-evidence-matrix.md @@ -7,7 +7,7 @@ This matrix is the working control sheet for ARSVIN profile expansion. It record | Status | Meaning | |---|---| | Verified-standard | Confirmed from a licensed applicable standard and recorded with edition and clause. | -| Verified-official | Confirmed from an official manufacturer or standards-body public document. | +| Verified-official | Confirmed from an official standards-body or manufacturer document. | | Verified-capture | Confirmed from an anonymized real-device SCL/PCAP pair. | | Verified-lab | Confirmed by byte-exact or interoperability testing with a real counterpart. | | Implemented-generic | Engine supports the mechanism, but no profile-specific compliance claim is made. | @@ -39,13 +39,15 @@ This matrix is the working control sheet for ARSVIN profile expansion. It record | Dataset reference | SCL-driven | Implemented-generic | Clause mapping and dataset-resolution fixtures. | | `confRev` | Parsed and emitted | Implemented-generic | Change/mismatch behavior tests. | | `smpCnt` | Parsed, emitted, monitored, and wrap-tested | Implemented-generic | Profile-specific wrap rules. | -| `smpSynch` | Compatibility modes implemented | Implemented-generic | Licensed semantics and real synchronized/unsynchronized captures. | +| `smpSynch` | Compatibility modes implemented | Implemented-generic | Licensed semantics and synchronized/unsynchronized captures. | | `smpRate` / `smpMod` | Parsed and emitted | Implemented-generic | Edition/profile-specific presence and interpretation rules. | | Multiple ASDUs per frame | Engine supports `1..8`; UI/docs historically emphasize `1/2/4/8` | Implemented-generic | Profile-specific allowed values and `nofASDU=6` evidence. | | Dataset-driven payload | Multiple integer, float, quality, time, enum, bit-string, and string kinds supported | Implemented-generic | Validate permitted SV bTypes, widths, order, and nesting. | | Unknown payload element | Preflight can reject unsupported layouts | Implemented-generic | Compatible-mode raw preservation design. | | PCAP read/write | Implemented and regression-tested | Implemented-generic | Golden external captures. | -| Timing health | Local TX and receiver statistics exist | Implemented-generic | Define measurement method and limitations against DANEO-like outcomes. | +| Observation window | Transport-independent facts, rate estimation, stable-field checks, and counter-wrap detection implemented | Implemented-generic | Integrate with live and PCAP Subscriber pipelines. | +| Profile matching | Evidence-aware weighted detector with explained matches, conflicts, and unknowns implemented | Implemented-generic | Add verified profile definitions after source review. | +| Configuration versus wire | Strict and compatible comparison engine implemented | Implemented-generic | Connect SCL bindings to observed stream facts in the UI. | ## 9-2LE-style profile research @@ -57,10 +59,10 @@ This matrix is the working control sheet for ARSVIN profile expansion. It record | Quality ordering and encoding | Engine supports quality fields | Provisional | Guideline vectors and device capture. | | Protection sampling variant | Product assumption exists | Provisional | Standard/guideline source plus 50 Hz and 60 Hz captures. | | High-rate variant | Roadmap candidate | Provisional | Standard/guideline source and performance fixture. | -| Nominal-frequency behavior | Not yet formalized as profile evidence | Unknown | Verify formula, rate, wrap, and frame-rate behavior. | +| Nominal-frequency behavior | Infrastructure can evaluate samples-per-cycle | Provisional | Verify formula, rate, wrap, and frame-rate behavior. | | Scaling convention | Not yet provenance-aware | Unknown | Verify raw-to-secondary convention and expose source. | -| Publisher interoperability | Generic output works | Provisional | At least one real relay/MU test per claimed variant. | -| Subscriber detection | SCL-assisted decode exists | Provisional | Confidence classifier and known/unknown/mismatch vectors. | +| Publisher interoperability | Generic output works | Provisional | At least one real counterpart test per claimed variant. | +| Subscriber detection | Explainable profile detector foundation exists | Implemented-generic | Add verified definitions and golden known/unknown/mismatch vectors. | ## IEC 61869-9 profile research @@ -70,59 +72,67 @@ This matrix is the working control sheet for ARSVIN profile expansion. It record | Configurable dataset behavior | Generic SCL engine can support it | Provisional | Standard requirement and real SCL evidence. | | Protection-oriented variant | Roadmap candidate | Provisional | Licensed rates, packing, payload, and timing requirements. | | Measurement/high-rate variant | Roadmap candidate | Provisional | Licensed requirements and sustained-throughput tests. | -| Sampling basis | Engine supports SCL `smpRate`/`smpMod` | Provisional | Verify samples-per-second versus samples-per-cycle semantics. | +| Sampling basis | Detector supports samples-per-second and samples-per-cycle definitions | Implemented-generic | Verify profile-specific semantics. | | `nofASDU` values | Engine mechanically supports `1..8` | Provisional | Profile-specific normative/recommended values. | -| Dataset shape and channel order | Must not be assumed fixed 4I+4V | Provisional | Standard plus vendor SCL matrix. | +| Dataset shape and channel order | Must not be assumed fixed 4I+4V | Provisional | Standard plus device SCL matrix. | | Scaling and units | Not yet profile-provenance-aware | Unknown | Standard clause, SCL source, and real values. | | Synchronization | Generic `smpSynch` and PTP tooling exist | Provisional | Standard linkage and capture evidence. | | Public support claim | Not supported yet | Unknown | All mandatory rows reach verified-standard and fixture/device evidence. | -## OMICRON-derived product benchmark matrix +## Engineering workflow outcomes -This table records product outcomes, not protocol requirements. +These are product outcomes, not protocol requirements and not references to any proprietary product implementation. -| Benchmark outcome | StationScout | DANEO 400 | ARSVIN target | -|---|---:|---:|---| -| SCL-first system view | Strong | Strong | Import streams and show expected-versus-observed state. | -| Configuration versus live comparison | Strong | Strong | Side-by-side profile, addressing, dataset, and timing mismatch. | -| Signal tracing | Strong | Partial for SV analysis | Trace SCL stream to observed stream and decoded channels. | -| Orphan detection | Not primary public emphasis | Explicit | List observed SV streams absent from SCL and expected streams absent from traffic. | -| MU parameter verification | Not primary public emphasis | Explicit | Validate stream fields, rate, packing, payload, synchronization, and quality. | -| Analog input versus SV output | Outside normal ARSVIN PC-only scope | Explicit | Future import/correlation path; do not imply calibrated measurement. | -| Packet interval/delay statistics | Not primary public emphasis | Explicit | Add interval, jitter, gaps, delay-input limitations, histograms, and evidence export. | -| Triggered recording | Test-case oriented | Explicit | Add software capture trigger rules after profile detector is stable. | -| PRP/HSR verification | Not primary public emphasis | Explicit | Separate redundancy milestone after base SV profiles. | -| Reusable tests and reports | Explicit | Explicit | Machine-readable expected-profile fixtures and repeatable reports. | +| Outcome | ARSVIN target | +|---|---| +| SCL-first system view | Import configured streams and show expected-versus-observed state. | +| Configuration versus live comparison | Compare addressing, dataset, packing, rates, and profile evidence side by side. | +| Signal tracing | Trace an SCL stream to observed traffic and decoded channels. | +| Orphan detection | List observed streams absent from SCL and expected streams absent from traffic. | +| MU parameter verification | Validate fields, rate, packing, payload, synchronization, and quality. | +| Packet interval statistics | Add intervals, jitter, gaps, bounded statistics, and evidence export. | +| Triggered recording | Add software trigger rules after profile detection is stable. | +| Redundancy verification | Keep PRP/HSR in a separate evidence-backed milestone. | +| Reusable tests and reports | Use machine-readable expected-profile fixtures and repeatable reports. | ## Fast-track implementation gates ### Gate 0 — Research register -Complete when: +Complete: - source editions are recorded, - licensed-source gaps are visible, -- every proposed profile constant is marked verified or provisional, -- and no unsupported public claim is introduced. +- proposed constants remain verified or provisional, +- no unsupported public claim is introduced. ### Gate 1 — Profile infrastructure -Complete when: +Complete in engine code: - observed facts are independent of WPF and Npcap, +- an observation accumulator estimates rate and counter wrap, - profile definitions carry source/evidence metadata, -- confidence scoring explains matches and conflicts, -- and unknown streams remain observable. +- confidence scoring explains matches, conflicts, and missing evidence, +- strict and compatible configuration comparison are available, +- unknown streams remain observable, +- built-in production catalog contains only a generic Layer-2 fallback. + +Remaining Gate 1 integration: + +- feed live and PCAP observations into the accumulator, +- bind expected SCL configuration to the comparison engine, +- expose results without visual noise in Subscriber. ### Gate 2 — Formalized 9-2LE-style support Complete when: - the authoritative guideline revision is reviewed, -- 50 Hz and 60 Hz fixtures exist where applicable, +- applicable 50 Hz and 60 Hz fixtures exist, - byte-exact Publisher and Subscriber tests pass, - scaling provenance is visible, -- and at least one real counterpart has been exercised. +- at least one real counterpart has been exercised. ### Gate 3 — IEC 61869-9 protection support @@ -132,7 +142,7 @@ Complete when: - preferred variant parameters are frozen, - SCL and PCAP fixtures pass, - real-device evidence exists, -- and performance remains stable. +- performance remains stable. ### Gate 4 — High-rate and redundancy support @@ -142,7 +152,7 @@ Complete when: - queues are bounded, - UI rendering is decoupled from packet rate, - PRP/HSR behavior has dedicated evidence, -- and no false sample-gap diagnosis occurs on clean captures. +- no false sample-gap diagnosis occurs on clean captures. ## Definition of public `Supported` @@ -157,4 +167,4 @@ A profile can be labeled supported only when all are true: - performance baseline documented, - real-device evidence recorded, - support matrix updated, -- and claim language reviewed against IEC 61850-10 boundaries. +- claim language reviewed against IEC 61850-10 boundaries. diff --git a/docs/sv-profile-infrastructure.md b/docs/sv-profile-infrastructure.md new file mode 100644 index 0000000..691a7ba --- /dev/null +++ b/docs/sv-profile-infrastructure.md @@ -0,0 +1,94 @@ +# Sampled Values Profile Infrastructure + +ARSVIN separates observed wire facts, configured expectations, and profile claims. This keeps the engine useful for unknown traffic while preventing unverified profile constants from becoming production claims. + +## Data flow + +```text +Parsed live or PCAP frame + ↓ +SvFrameObservation + ↓ +SvObservationAccumulator + ↓ +SvObservedStreamFacts + ├── SvProfileDetector + └── SvConfigurationComparer +``` + +The profile infrastructure is part of the shared `ARSVIN.Engine` assembly and has no dependency on WPF or Npcap. + +## Observed facts + +An observation window can describe: + +- EtherType and APPID, +- destination MAC and VLAN, +- `svID` and dataset reference, +- `confRev`, +- ASDU count and payload length, +- declared sample rate and mode, +- measured frames and samples per second, +- observed sample-counter wrap, +- nominal frequency when supplied by trusted context, +- ordered dataset element signatures, +- unstable-field and insufficient-window diagnostics. + +Fields that change during the window become unknown with a diagnostic instead of producing a false stable value. + +## Profile definitions + +A profile definition can carry: + +- stable ID and display name, +- profile family, +- samples-per-cycle, samples-per-second, or custom sampling basis, +- expected EtherType, +- allowed ASDU counts, +- expected payload length, +- expected dataset count and ordered signature, +- nominal-frequency options, +- expected sample-counter wrap, +- rate tolerance, +- evidence status and source metadata. + +The built-in catalog currently contains only a generic SCL-driven Layer-2 SV fallback. Numeric definitions for named profiles remain outside the production catalog until the research gate is satisfied. + +## Explainable detection + +`SvProfileDetector` evaluates only facts present in both the observation and definition. Each evaluated field produces one of: + +- match, +- conflict, +- unknown. + +Results include weighted evidence, score, matched and conflicting weight, and confidence: + +```text +Unknown +Possible +Likely +Confirmed +Conflict +``` + +A high score is an engineering classification result, not a conformance certificate. + +## Configuration-versus-wire comparison + +`SvConfigurationComparer` compares configured expectations with observed facts for addressing, identifiers, dataset, revision, packing, payload, and declared sampling fields. + +Two modes are available: + +- **Strict** — mismatches become errors suitable for validation and live-transmission preflight. +- **Compatible** — mismatches become warnings suitable for troubleshooting and unfamiliar devices. + +Neither mode stops receive-side capture or decoding. Unknown and conflicting streams remain visible. + +## Next integration + +1. Feed parsed live and PCAP frames into `SvObservationAccumulator`. +2. Convert selected SCL stream bindings into `SvExpectedStreamConfiguration`. +3. Present one compact profile/confidence state per observed stream. +4. Show configuration mismatch details on demand rather than adding permanent visual noise. +5. Add profile-specific definitions only after source review and deterministic evidence. diff --git a/docs/sv-research-gate.md b/docs/sv-research-gate.md index b181f01..41c466a 100644 --- a/docs/sv-research-gate.md +++ b/docs/sv-research-gate.md @@ -1,6 +1,6 @@ # Sampled Values Standards and Evidence Research Gate -This document defines the evidence required before ARSVIN labels an IEC 61850 Sampled Values profile as supported. It intentionally separates verified standards requirements, vendor documentation, observed traffic, engineering inference, and product ideas. +This document defines the evidence required before ARSVIN labels an IEC 61850 Sampled Values profile as supported. It intentionally separates verified standards requirements, manufacturer documentation, observed traffic, engineering inference, and product ideas. ARSVIN is an open engineering tool. It is not an accredited conformance test system, calibrated merging unit, deterministic real-time platform, or substitute for licensed standards. @@ -13,11 +13,11 @@ Sampled Values interoperability cannot be established from a sample rate or payl - the SCL model and dataset, - profile-specific instrument-transformer requirements, - synchronization and timing behavior, -- the actual wire traffic, +- actual wire traffic, - publisher and subscriber behavior, -- and evidence from real devices. +- evidence from real devices. -No profile-specific constant should be added to the production catalog merely because it is common in articles, vendor screenshots, or engineering memory. +No profile-specific constant should be added to the production catalog merely because it is common in articles, screenshots, or engineering memory. ## Source hierarchy @@ -29,7 +29,7 @@ Use sources in this order: 4. Official manufacturer documentation. 5. Anonymized SCL and PCAP evidence from real merging units and subscribers. 6. Byte-exact interoperability tests with real devices. -7. Independent reference implementations and Wireshark as supporting evidence only. +7. Independent reference implementations and packet-analysis tools as supporting evidence only. 8. Engineering inference, clearly marked provisional. A lower-level source must not override a higher-level source. @@ -51,40 +51,34 @@ IEC 61869-9 must be reviewed from a licensed current copy before ARSVIN freezes Public catalogue metadata is sufficient to establish which documents must be reviewed. It is not sufficient to implement clause-level behavior. -## OMICRON benchmark findings +## Engineering workflow benchmark outcomes -### StationScout benchmark +ARSVIN uses generic engineering outcomes as product targets without naming, copying, or implying compatibility with proprietary tools. -StationScout is a benchmark for system-level engineering workflow rather than the primary reference for SV byte-level implementation. The official material emphasizes: +### System engineering outcomes - automatic visualization of SCL-based system structure, -- a clear status overview, +- clear configured and observed status, - signal tracing, -- comparison of configured and live system behavior, -- simulation of unavailable IEDs, +- configuration-versus-wire comparison, +- simulation-friendly test preparation, - reusable test cases, - automated assessments, -- and documented results. +- documented results. -ARSVIN should adopt the workflow principles, not copy the product UI or make unsupported feature claims. +### Sampled Values analysis outcomes -### DANEO 400 benchmark - -DANEO 400 is the stronger functional benchmark for ARSVIN Subscriber and merging-unit analysis. Official material describes: - -- SCL-based verification that expected GOOSE and Sampled Values are present, -- side-by-side visibility of configuration versus observed traffic, -- orphan stream detection, -- PRP and HSR redundancy comparison, -- merging-unit stream-parameter verification, -- comparison of conventional input waveforms against SV output, -- packet interval and packet delay statistics, -- detection of dropped samples, synchronization issues, quality problems, and traffic abnormalities, +- SCL-based verification that expected Sampled Values streams are present, +- side-by-side configuration and observed traffic, +- orphan and missing-stream detection, +- stream-parameter verification, +- packet interval and delay statistics with explicit limitations, +- dropped-sample, synchronization, quality, and traffic anomaly detection, - trigger-based recording, - time-aligned analysis, -- and report generation. +- repeatable evidence reports. -These capabilities define useful product outcomes, but they do not define protocol constants. +These outcomes guide product design. They do not define protocol constants and do not justify a standards-compliance claim. ## Product claim levels @@ -114,7 +108,7 @@ Research goals: - verify nominal-frequency-dependent behavior, - verify quality and synchronization fields, - collect representative SCL and PCAP fixtures, -- and document vendor variations. +- document implementation variations. Until the guideline and real evidence are reviewed, ARSVIN must retain the wording `9-2LE-style` rather than claim universal 9-2LE conformance. @@ -126,28 +120,43 @@ Research goals: - record every preferred variant relevant to protection and measurement, - verify configurable dataset expectations, - verify sampling basis and rate semantics, -- verify nofASDU expectations, +- verify `nofASDU` expectations, - verify scaling and unit rules, - verify counter behavior, - verify synchronization requirements, -- and validate against current merging-unit captures. +- validate against current merging-unit captures. -No rate, nofASDU value, scaling constant, or dataset shape is considered normative until entered in the evidence matrix with a clause reference. +No rate, `nofASDU` value, scaling constant, or dataset shape is considered normative until entered in the evidence matrix with a clause reference. ### Track C — Generic SCL-driven Layer-2 SV -The engine already supports a broader set of SCL-derived payload types than the current fixed 4I+4V workflow. Research must determine: +The engine supports a broader set of SCL-derived payload types than the current fixed 4I+4V workflow. Research must determine: - which bTypes are valid and useful in SV datasets, - how nested data attributes are flattened and ordered, - whether each width and representation is correct, -- how unknown or vendor-specific fields are preserved, -- and when compatible decoding is safe without claiming a known profile. +- how unknown or implementation-specific fields are preserved, +- when compatible decoding is safe without claiming a known profile. ### Track D — Routable SV Routable SV is a separate architectural program. It requires separate review of transport, session, security, routing, synchronization, and test requirements. It must not be mixed into the current Layer-2 profile expansion. +## Profile infrastructure now implemented + +The shared engine now includes a standards-neutral foundation: + +- `SvFrameObservation` for transport-independent frame facts, +- `SvObservationAccumulator` for stable-field checks, frame/sample-rate estimation, and observed counter-wrap detection, +- `SvProfileDefinition` with evidence status and source metadata, +- `SvProfileDetector` with weighted, explainable match/conflict/unknown outcomes, +- `SvConfigurationComparer` with strict and compatible configuration-versus-wire findings, +- a built-in generic Layer-2 fallback without unverified profile constants. + +The infrastructure is intentionally independent of WPF and Npcap. Unknown or conflicting traffic remains observable; comparison results do not stop capture or decoding. + +No specific 9-2LE or IEC 61869-9 numeric definition is built into the production catalog yet. + ## Required evidence package per profile Each supported profile needs: @@ -174,7 +183,7 @@ Real-device evidence may remain private when licensing or confidentiality requir A requirement may enter production only when: 1. Its source and edition are identified. -2. The relevant clause or official vendor section is recorded without copying protected standard text. +2. The relevant clause or official manufacturer section is recorded without copying protected standard text. 3. Its normative strength is classified as required, recommended, optional, or engineering convention. 4. A deterministic automated test protects the behavior where technically possible. 5. Conflicting evidence is documented instead of silently resolved. @@ -182,24 +191,17 @@ A requirement may enter production only when: 7. Unknown traffic remains observable and does not block capture. 8. Live transmission is blocked when a verified critical requirement fails. -## Immediate implementation order after the gate +## Immediate implementation order -1. Add source and evidence models; do not add unverified constants. -2. Add observed-stream facts and confidence-based detection infrastructure. -3. Formalize the current 9-2LE-style workflow using reviewed evidence. -4. Add configuration-versus-wire mismatch reporting. -5. Add scaling provenance and raw-value visibility. +1. Integrate parsed live and PCAP frames with the observation accumulator. +2. Convert SCL bindings into expected stream configuration. +3. Expose compact profile confidence and mismatch findings in Subscriber. +4. Add scaling provenance and raw-value visibility. +5. Formalize the current 9-2LE-style workflow using reviewed evidence. 6. Add IEC 61869-9 variants only after licensed clause review. 7. Add high-rate performance work after functional fixtures pass. 8. Add real-device interoperability evidence before public support claims. -## Official public references - -- IEC 61850 series catalogue: https://webstore.iec.ch/en/publication/6028 -- OMICRON StationScout: https://www.omicronenergy.com/en/products/stationscout/ -- OMICRON StationScout brochure: https://www.omicronenergy.com/download/document/A73FDBE4-EFBB-4673-A765-E58195B69D2B/ -- OMICRON DANEO 400: https://www.omicronenergy.com/en/products/daneo-400/ -- OMICRON DANEO 400 brochure: https://www.omicronenergy.com/download/document/8C945CAA-C82D-4EFE-A1D8-BE6A52691FE6/ -- OMICRON IEC 61850 thematic introduction: https://www.omicronenergy.com/download/document/918A983B-DD8B-43AE-938A-168E40ECB8C1/ +## Public reference boundary -These links support public product and catalogue research. They do not replace licensed standards. \ No newline at end of file +Public repository documentation may cite standards publications, implementation guidelines, and generic evidence categories. Proprietary product names and comparative product marketing are intentionally excluded from ARSVIN documentation. Internal research notes may retain source attribution outside the public repository when needed for traceability. diff --git a/samples/sv-evidence/README.md b/samples/sv-evidence/README.md index 43969f0..a897394 100644 --- a/samples/sv-evidence/README.md +++ b/samples/sv-evidence/README.md @@ -21,6 +21,8 @@ samples/sv-evidence// Large, proprietary, or restricted captures should remain outside the public repository. Add a synthetic, non-sensitive fixture that reproduces the same protocol behavior and record the private evidence identifier in the case README. +Use `profile-definition-template.json` when preparing a research candidate. Unknown values must remain null or empty until verified. + ## Required metadata Record: @@ -39,11 +41,11 @@ Record: - observed sample-counter wrap, - payload bytes per ASDU, - synchronization source and state, -- independent verification tool, +- independent verification method, - capture method, - authorization and redaction status, - expected profile claim, -- and known deviations. +- known deviations. ## Redaction rules @@ -65,22 +67,22 @@ Anonymization must not silently change the behavior being tested. |---|---| | E0 | Engineering hypothesis only. | | E1 | Synthetic frame or SCL fixture. | -| E2 | Independent decoder or Wireshark agrees with the synthetic fixture. | +| E2 | An independent packet decoder agrees with the synthetic fixture. | | E3 | Anonymized real-device capture. | | E4 | Real Publisher-to-Subscriber or MU-to-relay interoperability test. | -| E5 | Repeatable multi-vendor evidence. | +| E5 | Repeatable multi-implementation evidence. | Public profile support requires licensed-source review plus at least E3 evidence. Lab-interoperable status requires E4. ## Independent verification -Where practical, record results from at least one independent tool: +Where practical, record results from at least one independent path: -- Wireshark packet dissection, -- a vendor engineering/test tool, -- OMICRON DANEO or another measurement analyzer, +- an independent packet dissector, +- a separate engineering or measurement analyzer, - a relay event or diagnostic log, -- or a separate reference implementation. +- a separate reference implementation, +- byte-level comparison against a reviewed fixture. Independent tools are supporting evidence. They do not replace the applicable standard. @@ -99,7 +101,7 @@ Include: - invalid/questionable/test/oldData quality where applicable, - synchronization state changes, - unknown/orphan stream, -- and malformed frame handling. +- malformed frame handling. ## Capture timing guidance @@ -109,14 +111,14 @@ For rate and jitter analysis, capture long enough to distinguish startup transie ```json { - "caseId": "synthetic-9-2le-style-50hz-001", + "caseId": "synthetic-profile-001", "claimStatus": "research-candidate", "sourceEdition": "pending-licensed-review", "transport": "layer-2", "expected": { "appId": "0x4001", - "asduPerFrame": 1, - "payloadBytesPerAsdu": 64 + "asduPerFrame": null, + "payloadBytesPerAsdu": null }, "observed": { "sampleRate": null, @@ -128,4 +130,4 @@ For rate and jitter analysis, capture long enough to distinguish startup transie } ``` -Do not fill unknown fields with remembered or assumed values. \ No newline at end of file +Do not fill unknown fields with remembered or assumed values. diff --git a/samples/sv-evidence/profile-definition-template.json b/samples/sv-evidence/profile-definition-template.json new file mode 100644 index 0000000..69754d3 --- /dev/null +++ b/samples/sv-evidence/profile-definition-template.json @@ -0,0 +1,28 @@ +{ + "id": "research-candidate-id", + "displayName": "Research candidate profile", + "family": "Unverified profile family", + "samplingBasis": "Custom", + "expectedEtherType": "0x88BA", + "allowedAsduPerFrame": [], + "expectedPayloadBytesPerAsdu": null, + "expectedDataSetElementCount": null, + "expectedDataSetSignature": [], + "expectedSamplesPerCycle": null, + "expectedSamplesPerSecond": null, + "allowedNominalFrequenciesHz": [], + "expectedCounterWrap": null, + "rateTolerancePercent": 1.0, + "evidenceStatus": "ResearchCandidate", + "sources": [ + { + "sourceId": "replace-with-source-register-id", + "description": "Record edition and clause outside protected standard text.", + "status": "ResearchCandidate" + } + ], + "notes": [ + "Do not copy this template into the production profile catalog until the research gate is satisfied.", + "Keep unknown values null or empty rather than inferring them." + ] +} diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index c6db348..9d07f53 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -1,10 +1,10 @@ [CmdletBinding()] param( [ValidateRange(0, 100)] - [double] $MinimumLineCoverage = 60, + [double] $MinimumLineCoverage = 70, [ValidateRange(0, 100)] - [double] $MinimumWholeEngineLineCoverage = 10.5, + [double] $MinimumWholeEngineLineCoverage = 13, [switch] $NoRestore ) diff --git a/scripts/validate-public-neutrality.py b/scripts/validate-public-neutrality.py new file mode 100644 index 0000000..91ebdcb --- /dev/null +++ b/scripts/validate-public-neutrality.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Reject proprietary comparison wording from active public repository content. + +The blocked tokens are assembled from character codes so this validator does not +re-publish the terms it is designed to exclude. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "artifacts" / "public-neutrality-report.txt" +PUBLIC_PATHS = ( + ROOT / "README.md", + ROOT / "CHANGELOG.md", + ROOT / "docs", + ROOT / "site", + ROOT / "samples", +) + +BLOCKED = ( + "".join(map(chr, (79, 77, 73, 67, 82, 79, 78))), + "".join(map(chr, (83, 116, 97, 116, 105, 111, 110, 83, 99, 111, 117, 116))), + "".join(map(chr, (68, 65, 78, 69, 79))), +) +TEXT_SUFFIXES = { + ".md", ".txt", ".html", ".htm", ".css", ".js", ".json", ".xml", + ".yml", ".yaml", ".ps1", ".py", ".cs", ".csproj", ".sln", ".iss", +} + + +def iter_files(path: Path): + if path.is_file(): + yield path + return + if path.is_dir(): + for candidate in path.rglob("*"): + if candidate.is_file() and candidate.suffix.lower() in TEXT_SUFFIXES: + yield candidate + + +def write_report(lines: list[str]) -> None: + REPORT.parent.mkdir(parents=True, exist_ok=True) + REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + violations: list[str] = [] + for public_path in PUBLIC_PATHS: + for file_path in iter_files(public_path): + text = file_path.read_text(encoding="utf-8", errors="replace") + folded = text.casefold() + for token in BLOCKED: + if token.casefold() in folded: + relative = file_path.relative_to(ROOT).as_posix() + violations.append(f"{relative}: prohibited proprietary comparison term") + + unique_violations = sorted(set(violations)) + if unique_violations: + lines = ["Public terminology neutrality validation failed:"] + lines.extend(f"- {violation}" for violation in unique_violations) + write_report(lines) + print("\n".join(lines), file=sys.stderr) + return 1 + + success = ["Public terminology neutrality validation passed."] + write_report(success) + print(success[0]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/index.html b/site/index.html index 0947593..6daf9b0 100644 --- a/site/index.html +++ b/site/index.html @@ -3,8 +3,8 @@ - ARSVIN — Open-Source IEC 61850 Sampled Values Publisher & Subscriber - + ARSVIN — Open-Source IEC 61850 Sampled Values Publisher & Subscriber + @@ -12,7 +12,7 @@ - + @@ -44,12 +44,12 @@ "codeRepository": "https://github.com/masarray/arsvin", "url": "https://masarray.github.io/arsvin/", "downloadUrl": "https://github.com/masarray/arsvin/releases/latest", + "image": "https://masarray.github.io/arsvin/assets/arsvin-social-preview.png", "screenshot": [ "https://masarray.github.io/arsvin/assets/arsvin-publisher-sv-setup.webp", "https://masarray.github.io/arsvin/assets/arsvin-subscriber-live-analysis.webp", "https://masarray.github.io/arsvin/assets/arsvin-subscriber-waveform-phasor.webp" ], - "image": "https://masarray.github.io/arsvin/assets/arsvin-social-preview.png", "author": { "@type": "Person", "name": "Ari Sulistiono", @@ -69,7 +69,7 @@ "name": "What is ARSVIN?", "acceptedAnswer": { "@type": "Answer", - "text": "ARSVIN is an Apache-2.0 IEC 61850 Sampled Values engineering suite for Windows. It includes an SCL-driven SV Publisher and ArSubsv, a live and PCAP Subscriber analysis companion." + "text": "ARSVIN is an Apache-2.0 IEC 61850 Sampled Values engineering suite for Windows. It includes an SCL-driven Publisher and a live and PCAP Subscriber analysis companion." } }, { @@ -106,7 +106,6 @@ ARSVIN - - GitHub @@ -124,25 +122,22 @@

Apache-2.0 · Windows x64 · IEC 61850 laboratory engineering

Sampled Values publishing and analysis, without a black box.

-

ARSVIN combines an SCL-driven SV Publisher with a focused Subscriber companion for live capture, PCAP replay, decoded values, waveform, phasor, RMS, stream health, and evidence export.

- +

ARSVIN combines an SCL-driven SV Publisher with a focused Subscriber companion for live capture, PCAP replay, decoded values, waveform, phasor, RMS, stream health, profile evidence, and engineering reports.

-
Open source Portable EXEs Installer - SHA-256 checksums + SBOM + SHA-256
-
- ArSubsv live analysis interface showing channel values, a phasor display, voltage waveform, and current waveform -
Real ArSubsv workspace — channel data, phasor, voltage, and current analysis.
+ ArSubsv live analysis interface showing channel values, phasor, voltage waveform, and current waveform +
Real Subscriber workspace — channel data, phasor, voltage, and current analysis.
@@ -152,7 +147,6 @@

Sampled Values publishing and analysis, without a black box.

Generate the stream. Inspect what arrives.

Publisher and Subscriber remain separate applications so each workflow stays understandable, testable, and honest about what it proves.

-
TX
@@ -161,20 +155,19 @@

ARSVIN Publisher

Build repeatable SV streams from SCL, manual values, per-phase scenarios, waveform shaping, or COMTRADE records.

  • SCL/SCD stream setup
  • -
  • nofASDU 1 / 2 / 4 / 8
  • -
  • TX timing health
  • -
  • Generated PCAP + Markdown report
  • +
  • Dataset-aware payload generation
  • +
  • Multi-ASDU frame packing
  • +
  • Generated PCAP and evidence report
-
RX

ArSubsv Subscriber

Discover and decode live or recorded SV traffic, bind streams to SCL, and inspect receiver-side behavior.

    -
  • Live Npcap capture + PCAP import
  • +
  • Live Npcap capture and PCAP import
  • Stream and smpCnt health
  • Waveform, phasor, and RMS
  • Receiver evidence report
  • @@ -189,21 +182,18 @@

    ArSubsv Subscriber

    Actual Windows interfaces

    See the engineering workflow before downloading.

    -

    These are real captures from the Publisher and Subscriber applications—not generic product mockups.

    +

    These are captures from the Publisher and Subscriber applications, not generic product mockups.

    -
    ARSVIN Publisher SV Setup with SCL stream selection, source modes, network fields, and manual output controls
    Publisher SV Setup — stream, adapter, network, mode, and output configuration.
    -
    ArSubsv live analysis with channel table, phasor plot, voltage waveform, and current waveform
    Subscriber analysis — decoded channels, phasor, voltage, and current views.
    -
    ArSubsv stream monitor with discovered SV streams, voltage and current waveforms, phasor view, and RMS values
    Stream monitor — discovered Sampled Values streams, waveform, phasor, and RMS inspection.
    @@ -216,12 +206,11 @@

    See the engineering workflow before downloading.

    Engineering workflow

    Useful visibility from configuration to evidence.

-
-
01

Configure from SCL

Reuse APPID, destination MAC, VLAN, svID, dataset, confRev, sample rate, and frame packing.

-
02

Generate scenarios

Use manual values, ramps, sequencers, faults, sequence components, harmonics, DC offset, phase jumps, or COMTRADE replay.

-
03

Watch health

Expose publisher timing, receiver continuity, late frames, missed schedules, sample counters, payload layout, and decoded values.

-
04

Preserve evidence

Export generated PCAP and Markdown reports for independent review, troubleshooting, and repeatable laboratory records.

+
01

Configure from SCL

Reuse APPID, destination MAC, VLAN, svID, dataset, confRev, sample rate, and packing.

+
02

Generate scenarios

Use manual values, ramps, faults, waveform shaping, sequence components, or COMTRADE replay.

+
03

Inspect stream health

Expose timing, continuity, counters, payload layout, decoded values, and configuration differences.

+
04

Preserve evidence

Export PCAP and Markdown reports for independent review, troubleshooting, and repeatable laboratory records.

@@ -230,16 +219,14 @@

Useful visibility from configuration to evidence.

Search-indexable engineering documentation

Understand the workflow before sending a frame.

-

Every repository guide is published as a dedicated HTML page with canonical metadata, structured data, internal navigation, and sitemap discovery.

+

Repository guides are published as dedicated HTML pages with canonical metadata, structured data, navigation, and sitemap discovery.

-
-
01

Quick Start

Install the suite, select Publisher or Subscriber, and begin with an authorized dry-run or isolated link.

-
02

SV Profile Support

Review current Sampled Values profile support, packing behavior, and explicit technical boundaries.

-
03

COMTRADE Replay

Map analog records into repeatable Sampled Values publishing scenarios with documented scaling assumptions.

-
04

Subscriber Verification

Inspect live or recorded streams, continuity, decoded values, waveform, phasor, RMS, and receiver evidence.

+
01

Quick Start

Install the suite and begin with an authorized dry run, PCAP, or isolated link.

+
02

SV Profile Support

Review current profile support, generic payload behavior, and explicit technical boundaries.

+
03

Subscriber Verification

Inspect live or recorded streams, continuity, decoded values, waveform, phasor, RMS, and evidence.

+
04

Safety Boundaries

Understand laboratory assumptions, evidence limits, and responsible live-network use.

- @@ -250,36 +237,31 @@

Understand the workflow before sending a frame.

Windows release options

Installer or direct portable applications.

-

All public release files use stable names so the latest download links remain predictable.

+

Public release files use stable names so the latest download links remain predictable.

- - -

Current binaries are unsigned and may trigger a Windows SmartScreen warning. Npcap is not bundled; install it separately from the official Npcap website for live network features. Verify downloads with SHA256SUMS.txt.

+

Current binaries are unsigned and may trigger a Windows SmartScreen warning. Npcap is not bundled. Verify downloads with SHA256SUMS.txt and review the release SBOM.

@@ -301,10 +283,9 @@

Designed for controlled laboratory use.

FAQ

Clear answers before live traffic.

-
What does the Subscriber prove? -

It proves that the selected PC/NIC received and decoded the stream. It does not prove that a relay or other IED consumed the multicast traffic.

+

It proves that the selected PC or NIC received and decoded the stream. It does not prove that a relay or other IED consumed the multicast traffic.

Does ARSVIN require Npcap? @@ -312,10 +293,10 @@

Clear answers before live traffic.

Is the installer code-signed? -

Not currently. Release assets are accompanied by SHA-256 checksums, but Windows may still show an unknown-publisher warning.

+

Not currently. Release assets include SHA-256 checksums, an SBOM, and repository workflow provenance, but Windows may still show an unknown-publisher warning.

- Can ARSVIN replace an Omicron-class test set or certified analyzer? + Can ARSVIN replace a calibrated test set or certified analyzer?

No. ARSVIN is transparent engineering software for controlled laboratory workflows, education, development, and troubleshooting support.

diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs new file mode 100644 index 0000000..77142ab --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvConfigurationComparer.cs @@ -0,0 +1,197 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvComparisonMode +{ + Strict, + Compatible +} + +public enum SvConfigurationFindingSeverity +{ + Info, + Warning, + Error +} + +public sealed record SvExpectedStreamConfiguration +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int? AsduPerFrame { get; init; } + public int? PayloadBytesPerAsdu { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); +} + +public sealed record SvConfigurationFinding( + SvConfigurationFindingSeverity Severity, + string Code, + string Field, + string Expected, + string Observed, + string Message); + +public sealed record SvConfigurationComparisonResult +{ + public SvComparisonMode Mode { get; init; } + public IReadOnlyList Findings { get; init; } + = Array.Empty(); + + public bool HasBlockingErrors => Findings.Any(item => item.Severity == SvConfigurationFindingSeverity.Error); + public int ErrorCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Error); + public int WarningCount => Findings.Count(item => item.Severity == SvConfigurationFindingSeverity.Warning); + public bool IsExactMatch => Findings.Count == 0; +} + +public sealed class SvConfigurationComparer +{ + public SvConfigurationComparisonResult Compare( + SvExpectedStreamConfiguration expected, + SvObservedStreamFacts observed, + SvComparisonMode mode) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(observed); + + var findings = new List(); + CompareNullable("SV_ETHERTYPE", "EtherType", expected.EtherType, observed.EtherType, value => $"0x{value:X4}", mode, findings); + CompareNullable("SV_APPID", "APPID", expected.AppId, observed.AppId, value => $"0x{value:X4}", mode, findings); + CompareText("SV_DEST_MAC", "Destination MAC", expected.DestinationMac, observed.DestinationMac, mode, findings); + CompareNullable("SV_VLAN_ID", "VLAN ID", expected.VlanId, observed.VlanId, value => value.ToString(), mode, findings); + CompareNullable("SV_VLAN_PRIORITY", "VLAN priority", expected.VlanPriority, observed.VlanPriority, value => value.ToString(), mode, findings); + CompareText("SV_ID", "svID", expected.SvId, observed.SvId, mode, findings); + CompareText("SV_DATASET", "Dataset reference", expected.DataSetReference, observed.DataSetReference, mode, findings); + CompareNullable("SV_CONFREV", "confRev", expected.ConfigurationRevision, observed.ConfigurationRevision, value => value.ToString(), mode, findings); + CompareNullable("SV_ASDU_COUNT", "ASDU per frame", expected.AsduPerFrame, observed.AsduPerFrame, value => value.ToString(), mode, findings); + CompareNullable("SV_PAYLOAD_LENGTH", "Payload bytes per ASDU", expected.PayloadBytesPerAsdu, observed.PayloadBytesPerAsdu, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_RATE", "Declared sample rate", expected.DeclaredSampleRate, observed.DeclaredSampleRate, value => value.ToString(), mode, findings); + CompareNullable("SV_SAMPLE_MODE", "Declared sample mode", expected.DeclaredSampleMode, observed.DeclaredSampleMode, value => value.ToString(), mode, findings); + CompareSignature(expected.DataSetSignature, observed.DataSetSignature, mode, findings); + + return new SvConfigurationComparisonResult + { + Mode = mode, + Findings = findings + }; + } + + private static void CompareText( + string code, + string field, + string expected, + string observed, + SvComparisonMode mode, + List findings) + { + if (string.IsNullOrWhiteSpace(expected)) + return; + + if (string.IsNullOrWhiteSpace(observed)) + { + findings.Add(Missing(code, field, expected, mode)); + return; + } + + if (!string.Equals(NormalizeText(expected), NormalizeText(observed), StringComparison.OrdinalIgnoreCase)) + findings.Add(Mismatch(code, field, expected, observed, mode)); + } + + private static void CompareNullable( + string code, + string field, + T? expected, + T? observed, + Func format, + SvComparisonMode mode, + List findings) + where T : struct, IEquatable + { + if (!expected.HasValue) + return; + if (!observed.HasValue) + { + findings.Add(Missing(code, field, format(expected.Value), mode)); + return; + } + if (!expected.Value.Equals(observed.Value)) + findings.Add(Mismatch(code, field, format(expected.Value), format(observed.Value), mode)); + } + + private static void CompareSignature( + IReadOnlyList expected, + IReadOnlyList observed, + SvComparisonMode mode, + List findings) + { + if (expected.Count == 0) + return; + if (observed.Count == 0) + { + findings.Add(Missing("SV_DATASET_SIGNATURE", "Dataset signature", SignatureText(expected), mode)); + return; + } + + var expectedKeys = expected.Select(ToKey).ToArray(); + var observedKeys = observed.Select(ToKey).ToArray(); + if (!expectedKeys.SequenceEqual(observedKeys, StringComparer.Ordinal)) + { + findings.Add(Mismatch( + "SV_DATASET_SIGNATURE", + "Dataset signature", + SignatureText(expected), + SignatureText(observed), + mode)); + } + } + + private static SvConfigurationFinding Missing( + string code, + string field, + string expected, + SvComparisonMode mode) + => new( + Severity(mode), + $"{code}_MISSING", + field, + expected, + "-", + $"Observed traffic does not expose the expected {field}. Capture and decoding remain active."); + + private static SvConfigurationFinding Mismatch( + string code, + string field, + string expected, + string observed, + SvComparisonMode mode) + => new( + Severity(mode), + $"{code}_MISMATCH", + field, + expected, + observed, + $"Configured {field} differs from observed traffic. Capture and decoding remain active."); + + private static SvConfigurationFindingSeverity Severity(SvComparisonMode mode) + => mode == SvComparisonMode.Strict + ? SvConfigurationFindingSeverity.Error + : SvConfigurationFindingSeverity.Warning; + + private static string NormalizeText(string value) + => value.Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(":", string.Empty, StringComparison.Ordinal) + .Trim(); + + private static string SignatureText(IReadOnlyList signature) + => string.Join(", ", signature.Select(item => item.NormalizedBType)); + + private static string ToKey(SvDatasetElementSignature element) + => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs new file mode 100644 index 0000000..1953a7d --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvObservationAccumulator.cs @@ -0,0 +1,191 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public sealed record SvFrameObservation +{ + public DateTimeOffset Timestamp { get; init; } + public ushort EtherType { get; init; } + public ushort AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint ConfigurationRevision { get; init; } + public int PayloadBytesPerAsdu { get; init; } + public IReadOnlyList SampleCounts { get; init; } = Array.Empty(); + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + + public int AsduPerFrame => SampleCounts.Count; + + public void Validate() + { + if (SampleCounts.Count == 0) + throw new ArgumentException("An SV frame observation requires at least one ASDU sample counter."); + if (PayloadBytesPerAsdu <= 0) + throw new ArgumentOutOfRangeException(nameof(PayloadBytesPerAsdu)); + } +} + +public sealed class SvObservationAccumulator +{ + private readonly List _observations = []; + + public int Count => _observations.Count; + + public void Add(SvFrameObservation observation) + { + ArgumentNullException.ThrowIfNull(observation); + observation.Validate(); + _observations.Add(observation); + } + + public void Clear() => _observations.Clear(); + + public SvObservedStreamFacts BuildFacts() + { + if (_observations.Count == 0) + return new SvObservedStreamFacts(); + + var ordered = _observations.OrderBy(item => item.Timestamp).ToArray(); + var diagnostics = new List(); + var first = ordered[0]; + var last = ordered[^1]; + + var durationSeconds = (last.Timestamp - first.Timestamp).TotalSeconds; + double? framesPerSecond = null; + double? samplesPerSecond = null; + if (ordered.Length >= 2 && durationSeconds > 0) + { + framesPerSecond = (ordered.Length - 1) / durationSeconds; + var averageAsduPerFrame = ordered.Average(item => item.AsduPerFrame); + samplesPerSecond = framesPerSecond * averageAsduPerFrame; + } + else + { + diagnostics.Add("At least two timestamped frames are required to estimate stream rate."); + } + + var counterWrap = DetectCounterWrap(ordered, diagnostics); + + return new SvObservedStreamFacts + { + EtherType = StableValue(ordered.Select(item => item.EtherType), "EtherType", diagnostics), + AppId = StableValue(ordered.Select(item => item.AppId), "APPID", diagnostics), + DestinationMac = StableString(ordered.Select(item => item.DestinationMac), "destination MAC", diagnostics), + VlanId = StableNullableValue(ordered.Select(item => item.VlanId), "VLAN ID", diagnostics), + VlanPriority = StableNullableValue(ordered.Select(item => item.VlanPriority), "VLAN priority", diagnostics), + SvId = StableString(ordered.Select(item => item.SvId), "svID", diagnostics), + DataSetReference = StableString(ordered.Select(item => item.DataSetReference), "dataset reference", diagnostics), + ConfigurationRevision = StableValue(ordered.Select(item => item.ConfigurationRevision), "confRev", diagnostics), + AsduPerFrame = StableValue(ordered.Select(item => item.AsduPerFrame), "ASDU count", diagnostics), + PayloadBytesPerAsdu = StableValue(ordered.Select(item => item.PayloadBytesPerAsdu), "payload length", diagnostics), + ObservedFramesPerSecond = framesPerSecond, + ObservedSamplesPerSecond = samplesPerSecond, + ObservedCounterWrap = counterWrap, + DeclaredSampleRate = StableNullableValue(ordered.Select(item => item.DeclaredSampleRate), "declared sample rate", diagnostics), + DeclaredSampleMode = StableNullableValue(ordered.Select(item => item.DeclaredSampleMode), "declared sample mode", diagnostics), + NominalFrequencyHz = StableNullableValue(ordered.Select(item => item.NominalFrequencyHz), "nominal frequency", diagnostics), + DataSetSignature = StableSignature(ordered.Select(item => item.DataSetSignature), diagnostics), + ObservationCount = ordered.Length, + FirstTimestamp = first.Timestamp, + LastTimestamp = last.Timestamp, + Diagnostics = diagnostics + }; + } + + private static int? DetectCounterWrap( + IReadOnlyList observations, + List diagnostics) + { + var sampleCounts = observations.SelectMany(item => item.SampleCounts).ToArray(); + if (sampleCounts.Length < 2) + return null; + + var wraps = new HashSet(); + for (var index = 1; index < sampleCounts.Length; index++) + { + var previous = sampleCounts[index - 1]; + var current = sampleCounts[index]; + if (current < previous) + wraps.Add(previous + 1); + } + + if (wraps.Count == 1) + return wraps.Single(); + + if (wraps.Count > 1) + diagnostics.Add($"Multiple sample-counter wrap candidates were observed: {string.Join(", ", wraps.Order())}."); + + return null; + } + + private static T? StableValue( + IEnumerable values, + string field, + List diagnostics) + where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) + return distinct[0]; + + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static T? StableNullableValue( + IEnumerable values, + string field, + List diagnostics) + where T : struct + { + var distinct = values.Distinct().ToArray(); + if (distinct.Length == 1) + return distinct[0]; + + diagnostics.Add($"Observed {field} changed within the analysis window."); + return null; + } + + private static string StableString( + IEnumerable values, + string field, + List diagnostics) + { + var distinct = values + .Select(value => value ?? string.Empty) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (distinct.Length == 1) + return distinct[0]; + + diagnostics.Add($"Observed {field} changed within the analysis window."); + return string.Empty; + } + + private static IReadOnlyList StableSignature( + IEnumerable> signatures, + List diagnostics) + { + var normalized = signatures + .Select(signature => signature.Select(ToKey).ToArray()) + .ToArray(); + + if (normalized.Length == 0 || normalized.All(signature => signature.Length == 0)) + return Array.Empty(); + + var first = normalized[0]; + if (normalized.All(signature => signature.SequenceEqual(first, StringComparer.Ordinal))) + return signatures.First().ToArray(); + + diagnostics.Add("Observed dataset signature changed within the analysis window."); + return Array.Empty(); + } + + private static string ToKey(SvDatasetElementSignature element) + => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs new file mode 100644 index 0000000..13b5bbf --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileDetector.cs @@ -0,0 +1,399 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public sealed class SvProfileDetector +{ + private const int EtherTypeWeight = 5; + private const int AsduWeight = 12; + private const int PayloadWeight = 18; + private const int DataSetCountWeight = 12; + private const int DataSetSignatureWeight = 25; + private const int SamplingRateWeight = 25; + private const int NominalFrequencyWeight = 8; + private const int CounterWrapWeight = 15; + private const int MinimumPossibleWeight = 15; + private const int MinimumLikelyWeight = 40; + private const int MinimumConfirmedWeight = 70; + + public IReadOnlyList Detect( + SvObservedStreamFacts facts, + IEnumerable profiles) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profiles); + + return profiles + .Select(profile => Evaluate(facts, profile)) + .OrderByDescending(result => ConfidenceRank(result.Confidence)) + .ThenByDescending(result => result.ScorePercent) + .ThenByDescending(result => result.EvaluatedWeight) + .ThenBy(result => result.Profile.DisplayName, StringComparer.Ordinal) + .ToArray(); + } + + public SvProfileDetectionResult? DetectBest( + SvObservedStreamFacts facts, + IEnumerable profiles) + => Detect(facts, profiles).FirstOrDefault(); + + public SvProfileDetectionResult Evaluate( + SvObservedStreamFacts facts, + SvProfileDefinition profile) + { + ArgumentNullException.ThrowIfNull(facts); + ArgumentNullException.ThrowIfNull(profile); + profile.Validate(); + + var evidence = new List(); + + CompareNullable( + "EtherType", + profile.ExpectedEtherType, + facts.EtherType, + EtherTypeWeight, + value => $"0x{value:X4}", + evidence); + + CompareAllowed( + "ASDU per frame", + profile.AllowedAsduPerFrame, + facts.AsduPerFrame, + AsduWeight, + evidence); + + CompareNullable( + "Payload bytes per ASDU", + profile.ExpectedPayloadBytesPerAsdu, + facts.PayloadBytesPerAsdu, + PayloadWeight, + value => value.ToString(), + evidence); + + CompareNullable( + "Dataset element count", + profile.ExpectedDataSetElementCount, + facts.DataSetSignature.Count > 0 ? facts.DataSetSignature.Count : null, + DataSetCountWeight, + value => value.ToString(), + evidence); + + CompareSignature(profile.ExpectedDataSetSignature, facts.DataSetSignature, evidence); + CompareSampling(profile, facts, evidence); + + CompareAllowedDouble( + "Nominal frequency", + profile.AllowedNominalFrequenciesHz, + facts.NominalFrequencyHz, + NominalFrequencyWeight, + profile.RateTolerancePercent, + evidence); + + CompareNullable( + "Sample-counter wrap", + profile.ExpectedCounterWrap, + facts.ObservedCounterWrap, + CounterWrapWeight, + value => value.ToString(), + evidence); + + var evaluatedWeight = evidence + .Where(item => item.Outcome != SvProfileEvidenceOutcome.Unknown) + .Sum(item => item.Weight); + var matchedWeight = evidence + .Where(item => item.Outcome == SvProfileEvidenceOutcome.Match) + .Sum(item => item.Weight); + var conflictWeight = evidence + .Where(item => item.Outcome == SvProfileEvidenceOutcome.Conflict) + .Sum(item => item.Weight); + var score = evaluatedWeight == 0 + ? 0 + : Math.Round((double)matchedWeight / evaluatedWeight * 100, 2); + var hasDataSetSignatureMatch = HasMatch(evidence, "Dataset signature"); + var hasSamplingMatch = HasMatch(evidence, "Observed samples per second") || + HasMatch(evidence, "Samples per cycle"); + + return new SvProfileDetectionResult + { + Profile = profile, + Confidence = ResolveConfidence( + score, + matchedWeight, + conflictWeight, + evaluatedWeight, + hasDataSetSignatureMatch, + hasSamplingMatch), + ScorePercent = score, + MatchedWeight = matchedWeight, + ConflictWeight = conflictWeight, + EvaluatedWeight = evaluatedWeight, + Evidence = evidence + }; + } + + private static SvProfileConfidence ResolveConfidence( + double score, + int matchedWeight, + int conflictWeight, + int evaluatedWeight, + bool hasDataSetSignatureMatch, + bool hasSamplingMatch) + { + if (evaluatedWeight == 0) + return SvProfileConfidence.Unknown; + if (conflictWeight >= matchedWeight && conflictWeight > 0) + return SvProfileConfidence.Conflict; + if (conflictWeight == 0 && + score >= 90 && + evaluatedWeight >= MinimumConfirmedWeight && + hasDataSetSignatureMatch && + hasSamplingMatch) + { + return SvProfileConfidence.Confirmed; + } + if (score >= 70 && evaluatedWeight >= MinimumLikelyWeight) + return SvProfileConfidence.Likely; + if (score >= 45 && evaluatedWeight >= MinimumPossibleWeight) + return SvProfileConfidence.Possible; + if (conflictWeight > 0) + return SvProfileConfidence.Conflict; + return SvProfileConfidence.Unknown; + } + + private static int ConfidenceRank(SvProfileConfidence confidence) + => confidence switch + { + SvProfileConfidence.Confirmed => 4, + SvProfileConfidence.Likely => 3, + SvProfileConfidence.Possible => 2, + SvProfileConfidence.Unknown => 1, + SvProfileConfidence.Conflict => 0, + _ => 0 + }; + + private static bool HasMatch( + IEnumerable evidence, + string field) + => evidence.Any(item => + item.Field.Equals(field, StringComparison.Ordinal) && + item.Outcome == SvProfileEvidenceOutcome.Match); + + private static void CompareSampling( + SvProfileDefinition profile, + SvObservedStreamFacts facts, + List evidence) + { + switch (profile.SamplingBasis) + { + case SvSamplingBasis.SamplesPerSecond when profile.ExpectedSamplesPerSecond.HasValue: + CompareApproximate( + "Observed samples per second", + profile.ExpectedSamplesPerSecond.Value, + facts.ObservedSamplesPerSecond, + SamplingRateWeight, + profile.RateTolerancePercent, + evidence); + break; + + case SvSamplingBasis.SamplesPerCycle when profile.ExpectedSamplesPerCycle.HasValue: + if (!facts.ObservedSamplesPerSecond.HasValue || !facts.NominalFrequencyHz.HasValue) + { + evidence.Add(Unknown( + "Samples per cycle", + SamplingRateWeight, + profile.ExpectedSamplesPerCycle.Value.ToString("0.###"), + "-", + "Observed rate and nominal frequency are both required.")); + break; + } + + var observedSamplesPerCycle = facts.ObservedSamplesPerSecond.Value / facts.NominalFrequencyHz.Value; + CompareApproximate( + "Samples per cycle", + profile.ExpectedSamplesPerCycle.Value, + observedSamplesPerCycle, + SamplingRateWeight, + profile.RateTolerancePercent, + evidence); + break; + } + } + + private static void CompareSignature( + IReadOnlyList expected, + IReadOnlyList observed, + List evidence) + { + if (expected.Count == 0) + return; + if (observed.Count == 0) + { + evidence.Add(Unknown( + "Dataset signature", + DataSetSignatureWeight, + SignatureText(expected), + "-", + "No dataset signature is available for this observation window.")); + return; + } + + var expectedKeys = expected.Select(ToKey).ToArray(); + var observedKeys = observed.Select(ToKey).ToArray(); + var matches = expectedKeys.SequenceEqual(observedKeys, StringComparer.Ordinal); + evidence.Add(new SvProfileMatchEvidence( + "Dataset signature", + matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, + DataSetSignatureWeight, + SignatureText(expected), + SignatureText(observed), + matches + ? "Dataset element order and types match the profile definition." + : "Dataset element order or types conflict with the profile definition.")); + } + + private static void CompareAllowed( + string field, + IReadOnlyList allowed, + int? observed, + int weight, + List evidence) + { + if (allowed.Count == 0) + return; + if (!observed.HasValue) + { + evidence.Add(Unknown(field, weight, string.Join("/", allowed), "-", $"Observed {field} is unavailable.")); + return; + } + + var matches = allowed.Contains(observed.Value); + evidence.Add(new SvProfileMatchEvidence( + field, + matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, + weight, + string.Join("/", allowed), + observed.Value.ToString(), + matches ? $"Observed {field} is allowed." : $"Observed {field} is not allowed by the profile definition.")); + } + + private static void CompareAllowedDouble( + string field, + IReadOnlyList allowed, + double? observed, + int weight, + double tolerancePercent, + List evidence) + { + if (allowed.Count == 0) + return; + if (!observed.HasValue) + { + evidence.Add(Unknown(field, weight, string.Join("/", allowed.Select(value => value.ToString("0.###"))), "-", $"Observed {field} is unavailable.")); + return; + } + + var matches = allowed.Any(value => IsWithinTolerance(value, observed.Value, tolerancePercent)); + evidence.Add(new SvProfileMatchEvidence( + field, + matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, + weight, + string.Join("/", allowed.Select(value => value.ToString("0.###"))), + observed.Value.ToString("0.###"), + matches ? $"Observed {field} is allowed." : $"Observed {field} conflicts with the allowed values.")); + } + + private static void CompareApproximate( + string field, + double expected, + double? observed, + int weight, + double tolerancePercent, + List evidence) + { + if (!observed.HasValue) + { + evidence.Add(Unknown(field, weight, expected.ToString("0.###"), "-", $"Observed {field} is unavailable.")); + return; + } + + var matches = IsWithinTolerance(expected, observed.Value, tolerancePercent); + evidence.Add(new SvProfileMatchEvidence( + field, + matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, + weight, + expected.ToString("0.###"), + observed.Value.ToString("0.###"), + matches + ? $"Observed {field} is within {tolerancePercent:0.###}% tolerance." + : $"Observed {field} is outside {tolerancePercent:0.###}% tolerance.")); + } + + private static void CompareNullable( + string field, + T? expected, + T? observed, + int weight, + Func format, + List evidence) + where T : struct, IEquatable + { + if (!expected.HasValue) + return; + if (!observed.HasValue) + { + evidence.Add(Unknown(field, weight, format(expected.Value), "-", $"Observed {field} is unavailable.")); + return; + } + + var matches = expected.Value.Equals(observed.Value); + evidence.Add(new SvProfileMatchEvidence( + field, + matches ? SvProfileEvidenceOutcome.Match : SvProfileEvidenceOutcome.Conflict, + weight, + format(expected.Value), + format(observed.Value), + matches ? $"Observed {field} matches." : $"Observed {field} conflicts with the profile definition.")); + } + + private static bool IsWithinTolerance(double expected, double observed, double tolerancePercent) + { + if (expected == 0) + return observed == 0; + return Math.Abs(observed - expected) / Math.Abs(expected) * 100 <= tolerancePercent; + } + + private static SvProfileMatchEvidence Unknown( + string field, + int weight, + string expected, + string observed, + string message) + => new(field, SvProfileEvidenceOutcome.Unknown, weight, expected, observed, message); + + private static string SignatureText(IReadOnlyList signature) + => string.Join(", ", signature.Select(element => element.NormalizedBType)); + + private static string ToKey(SvDatasetElementSignature element) + => $"{element.NormalizedBType}|{element.NormalizedCdc}|{element.IsQuality}|{element.IsTimestamp}"; +} + +public static class SvProfileCatalog +{ + public static SvProfileDefinition GenericSclLayer2 { get; } = new() + { + Id = "generic-scl-layer2", + DisplayName = "Generic SCL-driven Layer-2 SV", + Family = "Generic Layer-2 SV", + SamplingBasis = SvSamplingBasis.Custom, + ExpectedEtherType = 0x88BA, + EvidenceStatus = SvProfileEvidenceStatus.ImplementedGeneric, + Sources = + [ + new SvProfileSourceEvidence( + "arsvin-engine", + "Generic Layer-2 SV mechanisms implemented by the shared engine without a profile-specific conformance claim.", + SvProfileEvidenceStatus.ImplementedGeneric) + ] + }; + + public static IReadOnlyList BuiltIn { get; } + = [GenericSclLayer2]; +} diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs new file mode 100644 index 0000000..2c05170 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileModels.cs @@ -0,0 +1,173 @@ +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvSamplingBasis +{ + Unspecified, + SamplesPerCycle, + SamplesPerSecond, + Custom +} + +public enum SvProfileEvidenceStatus +{ + ResearchCandidate, + ImplementedGeneric, + VerifiedStandard, + VerifiedCapture, + VerifiedLab +} + +public enum SvProfileConfidence +{ + Unknown, + Possible, + Likely, + Confirmed, + Conflict +} + +public enum SvProfileEvidenceOutcome +{ + Match, + Conflict, + Unknown +} + +public sealed record SvProfileSourceEvidence( + string SourceId, + string Description, + SvProfileEvidenceStatus Status); + +public sealed record SvDatasetElementSignature +{ + public string BType { get; init; } = string.Empty; + public string Cdc { get; init; } = string.Empty; + public bool IsQuality { get; init; } + public bool IsTimestamp { get; init; } + + public string NormalizedBType => Normalize(BType); + public string NormalizedCdc => Normalize(Cdc); + + private static string Normalize(string value) + => new((value ?? string.Empty) + .Where(char.IsLetterOrDigit) + .Select(char.ToUpperInvariant) + .ToArray()); +} + +public sealed record SvObservedStreamFacts +{ + public ushort? EtherType { get; init; } + public ushort? AppId { get; init; } + public string DestinationMac { get; init; } = string.Empty; + public ushort? VlanId { get; init; } + public byte? VlanPriority { get; init; } + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public uint? ConfigurationRevision { get; init; } + public int? AsduPerFrame { get; init; } + public int? PayloadBytesPerAsdu { get; init; } + public double? ObservedFramesPerSecond { get; init; } + public double? ObservedSamplesPerSecond { get; init; } + public int? ObservedCounterWrap { get; init; } + public ushort? DeclaredSampleRate { get; init; } + public ushort? DeclaredSampleMode { get; init; } + public double? NominalFrequencyHz { get; init; } + public IReadOnlyList DataSetSignature { get; init; } + = Array.Empty(); + public int ObservationCount { get; init; } + public DateTimeOffset? FirstTimestamp { get; init; } + public DateTimeOffset? LastTimestamp { get; init; } + public IReadOnlyList Diagnostics { get; init; } = Array.Empty(); +} + +public sealed record SvProfileDefinition +{ + public string Id { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Family { get; init; } = string.Empty; + public SvSamplingBasis SamplingBasis { get; init; } + public ushort? ExpectedEtherType { get; init; } + public IReadOnlyList AllowedAsduPerFrame { get; init; } = Array.Empty(); + public int? ExpectedPayloadBytesPerAsdu { get; init; } + public int? ExpectedDataSetElementCount { get; init; } + public IReadOnlyList ExpectedDataSetSignature { get; init; } + = Array.Empty(); + public double? ExpectedSamplesPerCycle { get; init; } + public double? ExpectedSamplesPerSecond { get; init; } + public IReadOnlyList AllowedNominalFrequenciesHz { get; init; } = Array.Empty(); + public int? ExpectedCounterWrap { get; init; } + public double RateTolerancePercent { get; init; } = 1.0; + public SvProfileEvidenceStatus EvidenceStatus { get; init; } = SvProfileEvidenceStatus.ResearchCandidate; + public IReadOnlyList Sources { get; init; } + = Array.Empty(); + + public void Validate() + { + if (string.IsNullOrWhiteSpace(Id)) + throw new InvalidOperationException("SV profile definition requires a stable ID."); + if (string.IsNullOrWhiteSpace(DisplayName)) + throw new InvalidOperationException($"SV profile '{Id}' requires a display name."); + if (Sources.Count == 0) + throw new InvalidOperationException($"SV profile '{Id}' requires at least one evidence source."); + if (Sources.Any(source => string.IsNullOrWhiteSpace(source.SourceId) || string.IsNullOrWhiteSpace(source.Description))) + throw new InvalidOperationException($"SV profile '{Id}' contains incomplete evidence-source metadata."); + if (RateTolerancePercent < 0 || RateTolerancePercent > 100) + throw new InvalidOperationException($"SV profile '{Id}' has an invalid rate tolerance."); + if (AllowedAsduPerFrame.Any(value => value <= 0)) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid ASDU-per-frame value."); + if (AllowedAsduPerFrame.Count != AllowedAsduPerFrame.Distinct().Count()) + throw new InvalidOperationException($"SV profile '{Id}' contains duplicate ASDU-per-frame values."); + if (ExpectedPayloadBytesPerAsdu is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid payload length."); + if (ExpectedDataSetElementCount is < 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid dataset element count."); + if (ExpectedDataSetElementCount.HasValue && + ExpectedDataSetSignature.Count > 0 && + ExpectedDataSetElementCount.Value != ExpectedDataSetSignature.Count) + { + throw new InvalidOperationException($"SV profile '{Id}' dataset count does not match its ordered signature."); + } + if (ExpectedCounterWrap is <= 1) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sample-counter wrap."); + if (AllowedNominalFrequenciesHz.Any(value => value <= 0 || double.IsNaN(value) || double.IsInfinity(value))) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid nominal frequency."); + if (ExpectedSamplesPerCycle is <= 0 || ExpectedSamplesPerSecond is <= 0) + throw new InvalidOperationException($"SV profile '{Id}' contains an invalid sampling expectation."); + if (ExpectedSamplesPerCycle.HasValue && ExpectedSamplesPerSecond.HasValue) + throw new InvalidOperationException($"SV profile '{Id}' cannot define both samples-per-cycle and samples-per-second expectations."); + + switch (SamplingBasis) + { + case SvSamplingBasis.SamplesPerCycle when !ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-cycle expectation."); + case SvSamplingBasis.SamplesPerSecond when !ExpectedSamplesPerSecond.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' requires a samples-per-second expectation."); + case SvSamplingBasis.SamplesPerCycle when ExpectedSamplesPerSecond.HasValue: + case SvSamplingBasis.SamplesPerSecond when ExpectedSamplesPerCycle.HasValue: + throw new InvalidOperationException($"SV profile '{Id}' sampling expectation conflicts with its sampling basis."); + } + } +} + +public sealed record SvProfileMatchEvidence( + string Field, + SvProfileEvidenceOutcome Outcome, + int Weight, + string Expected, + string Observed, + string Message); + +public sealed record SvProfileDetectionResult +{ + public SvProfileDefinition Profile { get; init; } = new(); + public SvProfileConfidence Confidence { get; init; } + public double ScorePercent { get; init; } + public int MatchedWeight { get; init; } + public int ConflictWeight { get; init; } + public int EvaluatedWeight { get; init; } + public IReadOnlyList Evidence { get; init; } + = Array.Empty(); + + public bool HasConflicts => Evidence.Any(item => item.Outcome == SvProfileEvidenceOutcome.Conflict); +} diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs new file mode 100644 index 0000000..fd86fc9 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvConfigurationComparerTests.cs @@ -0,0 +1,105 @@ +using AR.Iec61850.SampledValues.Profiles; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvConfigurationComparerTests +{ + [Fact] + public void StrictModeMarksConfigurationMismatchAsBlockingError() + { + var expected = CreateExpected(); + var observed = CreateObserved() with + { + AppId = 0x4002, + AsduPerFrame = 4 + }; + + var result = new SvConfigurationComparer().Compare(expected, observed, SvComparisonMode.Strict); + + Assert.True(result.HasBlockingErrors); + Assert.Equal(2, result.ErrorCount); + Assert.Contains(result.Findings, item => item.Code == "SV_APPID_MISMATCH"); + Assert.Contains(result.Findings, item => item.Code == "SV_ASDU_COUNT_MISMATCH"); + Assert.All(result.Findings, item => Assert.Contains("remain active", item.Message, StringComparison.Ordinal)); + } + + [Fact] + public void CompatibleModeKeepsMismatchNonBlocking() + { + var observed = CreateObserved() with { DataSetReference = "OTHER/LLN0$Different" }; + + var result = new SvConfigurationComparer().Compare(CreateExpected(), observed, SvComparisonMode.Compatible); + + Assert.False(result.HasBlockingErrors); + Assert.Equal(1, result.WarningCount); + Assert.Equal(SvConfigurationFindingSeverity.Warning, Assert.Single(result.Findings).Severity); + } + + [Fact] + public void EquivalentMacFormattingDoesNotCreateFalseMismatch() + { + var expected = CreateExpected() with { DestinationMac = "01-0C-CD-04-00-01" }; + + var result = new SvConfigurationComparer().Compare(expected, CreateObserved(), SvComparisonMode.Strict); + + Assert.True(result.IsExactMatch); + } + + [Fact] + public void MissingDatasetSignatureIsReportedWithoutThrowing() + { + var observed = CreateObserved() with { DataSetSignature = Array.Empty() }; + + var result = new SvConfigurationComparer().Compare(CreateExpected(), observed, SvComparisonMode.Compatible); + + var finding = Assert.Single(result.Findings); + Assert.Equal("SV_DATASET_SIGNATURE_MISSING", finding.Code); + Assert.Equal(SvConfigurationFindingSeverity.Warning, finding.Severity); + } + + private static SvExpectedStreamConfiguration CreateExpected() + => new() + { + EtherType = 0x88BA, + AppId = 0x4001, + DestinationMac = "01:0C:CD:04:00:01", + VlanId = 100, + VlanPriority = 4, + SvId = "MU01SV01", + DataSetReference = "MU01MUnn/LLN0$PhsMeas", + ConfigurationRevision = 7, + AsduPerFrame = 2, + PayloadBytesPerAsdu = 8, + DeclaredSampleRate = 80, + DeclaredSampleMode = 0, + DataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" }, + new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true } + ] + }; + + private static SvObservedStreamFacts CreateObserved() + => new() + { + EtherType = 0x88BA, + AppId = 0x4001, + DestinationMac = "01:0C:CD:04:00:01", + VlanId = 100, + VlanPriority = 4, + SvId = "MU01SV01", + DataSetReference = "MU01MUnn/LLN0$PhsMeas", + ConfigurationRevision = 7, + AsduPerFrame = 2, + PayloadBytesPerAsdu = 8, + DeclaredSampleRate = 80, + DeclaredSampleMode = 0, + DataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" }, + new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true } + ], + ObservationCount = 100 + }; +} diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs new file mode 100644 index 0000000..ac7202c --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvObservationAccumulatorTests.cs @@ -0,0 +1,79 @@ +using AR.Iec61850.SampledValues.Profiles; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvObservationAccumulatorTests +{ + [Fact] + public void BuildFactsCalculatesRatesAndCounterWrapWithoutTransportDependencies() + { + var accumulator = new SvObservationAccumulator(); + accumulator.Add(CreateObservation(0, [3998, 3999])); + accumulator.Add(CreateObservation(1, [0, 1])); + accumulator.Add(CreateObservation(2, [2, 3])); + + var facts = accumulator.BuildFacts(); + + Assert.Equal(3, facts.ObservationCount); + Assert.Equal((ushort)0x88BA, facts.EtherType); + Assert.Equal((ushort)0x4001, facts.AppId); + Assert.Equal(2, facts.AsduPerFrame); + Assert.Equal(64, facts.PayloadBytesPerAsdu); + Assert.Equal(1000, facts.ObservedFramesPerSecond!.Value, precision: 6); + Assert.Equal(2000, facts.ObservedSamplesPerSecond!.Value, precision: 6); + Assert.Equal(4000, facts.ObservedCounterWrap); + Assert.Empty(facts.Diagnostics); + } + + [Fact] + public void BuildFactsMarksChangingConfigurationAsUnknownInsteadOfThrowing() + { + var accumulator = new SvObservationAccumulator(); + accumulator.Add(CreateObservation(0, [10], configurationRevision: 1)); + accumulator.Add(CreateObservation(1, [11], configurationRevision: 2)); + + var facts = accumulator.BuildFacts(); + + Assert.Null(facts.ConfigurationRevision); + Assert.Contains(facts.Diagnostics, item => item.Contains("confRev changed", StringComparison.Ordinal)); + Assert.Equal(2, facts.ObservationCount); + } + + [Fact] + public void EmptyAccumulatorReturnsObservableUnknownFacts() + { + var facts = new SvObservationAccumulator().BuildFacts(); + + Assert.Equal(0, facts.ObservationCount); + Assert.Null(facts.AppId); + Assert.Empty(facts.Diagnostics); + } + + private static SvFrameObservation CreateObservation( + int milliseconds, + IReadOnlyList sampleCounts, + uint configurationRevision = 7) + => new() + { + Timestamp = DateTimeOffset.UnixEpoch.AddMilliseconds(milliseconds), + EtherType = 0x88BA, + AppId = 0x4001, + DestinationMac = "01:0C:CD:04:00:01", + VlanId = 100, + VlanPriority = 4, + SvId = "MU01SV01", + DataSetReference = "MU01MUnn/LLN0$PhsMeas", + ConfigurationRevision = configurationRevision, + PayloadBytesPerAsdu = 64, + SampleCounts = sampleCounts, + DeclaredSampleRate = 80, + DeclaredSampleMode = 0, + NominalFrequencyHz = 50, + DataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" }, + new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true } + ] + }; +} diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDefinitionTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDefinitionTests.cs new file mode 100644 index 0000000..1b4513b --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDefinitionTests.cs @@ -0,0 +1,94 @@ +using AR.Iec61850.SampledValues.Profiles; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvProfileDefinitionTests +{ + [Fact] + public void ValidGenericDefinitionPassesValidation() + { + SvProfileCatalog.GenericSclLayer2.Validate(); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void NonPositiveAsduCountIsRejected(int value) + { + var profile = CreateValidDefinition() with + { + AllowedAsduPerFrame = [value] + }; + + Assert.Throws(profile.Validate); + } + + [Fact] + public void InvalidRateToleranceIsRejected() + { + var profile = CreateValidDefinition() with + { + RateTolerancePercent = 101 + }; + + Assert.Throws(profile.Validate); + } + + [Fact] + public void EvidenceSourceIsRequired() + { + var profile = CreateValidDefinition() with + { + Sources = Array.Empty() + }; + + Assert.Throws(profile.Validate); + } + + [Fact] + public void SamplingBasisRequiresMatchingExpectation() + { + var profile = CreateValidDefinition() with + { + SamplingBasis = SvSamplingBasis.SamplesPerCycle, + ExpectedSamplesPerSecond = null, + ExpectedSamplesPerCycle = null + }; + + Assert.Throws(profile.Validate); + } + + [Fact] + public void DatasetCountMustMatchOrderedSignature() + { + var profile = CreateValidDefinition() with + { + ExpectedDataSetElementCount = 2, + ExpectedDataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32" } + ] + }; + + Assert.Throws(profile.Validate); + } + + private static SvProfileDefinition CreateValidDefinition() + => new() + { + Id = "valid-test-profile", + DisplayName = "Valid test profile", + Family = "Test", + SamplingBasis = SvSamplingBasis.SamplesPerSecond, + ExpectedSamplesPerSecond = 4000, + AllowedAsduPerFrame = [1], + Sources = + [ + new SvProfileSourceEvidence( + "test-source", + "Synthetic source metadata for deterministic validation tests.", + SvProfileEvidenceStatus.ResearchCandidate) + ] + }; +} diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs new file mode 100644 index 0000000..a0b8240 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileDetectorTests.cs @@ -0,0 +1,153 @@ +using AR.Iec61850.SampledValues.Profiles; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvProfileDetectorTests +{ + [Fact] + public void ExactSyntheticDefinitionProducesConfirmedExplainableResult() + { + var detector = new SvProfileDetector(); + var result = detector.Evaluate(CreateMatchingFacts(), CreateSyntheticProfile()); + + Assert.Equal(SvProfileConfidence.Confirmed, result.Confidence); + Assert.Equal(100, result.ScorePercent); + Assert.False(result.HasConflicts); + Assert.Contains(result.Evidence, item => + item.Field == "Dataset signature" && + item.Outcome == SvProfileEvidenceOutcome.Match); + Assert.Contains(result.Evidence, item => + item.Field == "Observed samples per second" && + item.Message.Contains("within", StringComparison.Ordinal)); + } + + [Fact] + public void ConflictingWireFactsReturnConflictWithFieldEvidence() + { + var facts = CreateMatchingFacts() with + { + AsduPerFrame = 4, + PayloadBytesPerAsdu = 80, + ObservedSamplesPerSecond = 2000, + ObservedCounterWrap = 2000 + }; + + var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile()); + + Assert.Equal(SvProfileConfidence.Conflict, result.Confidence); + Assert.True(result.HasConflicts); + Assert.Contains(result.Evidence, item => + item.Field == "Payload bytes per ASDU" && + item.Outcome == SvProfileEvidenceOutcome.Conflict); + Assert.Contains(result.Evidence, item => + item.Field == "Sample-counter wrap" && + item.Observed == "2000"); + } + + [Fact] + public void MissingObservedFactsRemainUnknownAndDoNotCreateFalseMatch() + { + var result = new SvProfileDetector().Evaluate(new SvObservedStreamFacts(), CreateSyntheticProfile()); + + Assert.Equal(SvProfileConfidence.Unknown, result.Confidence); + Assert.Equal(0, result.EvaluatedWeight); + Assert.All(result.Evidence, item => Assert.Equal(SvProfileEvidenceOutcome.Unknown, item.Outcome)); + } + + [Fact] + public void SparseGenericEvidenceCannotProduceFalseConfirmation() + { + var facts = new SvObservedStreamFacts + { + EtherType = 0x88BA, + ObservationCount = 1 + }; + + var result = new SvProfileDetector().Evaluate(facts, SvProfileCatalog.GenericSclLayer2); + + Assert.Equal(100, result.ScorePercent); + Assert.Equal(5, result.EvaluatedWeight); + Assert.Equal(SvProfileConfidence.Unknown, result.Confidence); + } + + [Fact] + public void MatchingStaticFieldsWithoutDatasetOrRateRemainOnlyPossible() + { + var facts = CreateMatchingFacts() with + { + ObservedSamplesPerSecond = null, + DataSetSignature = Array.Empty(), + NominalFrequencyHz = null, + ObservedCounterWrap = null + }; + + var result = new SvProfileDetector().Evaluate(facts, CreateSyntheticProfile()); + + Assert.Equal(SvProfileConfidence.Possible, result.Confidence); + Assert.DoesNotContain(result.Evidence, item => + item.Field == "Dataset signature" && + item.Outcome == SvProfileEvidenceOutcome.Match); + } + + [Fact] + public void BuiltInCatalogContainsOnlyGenericEvidenceBackedFallback() + { + var profile = Assert.Single(SvProfileCatalog.BuiltIn); + + Assert.Equal("generic-scl-layer2", profile.Id); + Assert.Equal(SvProfileEvidenceStatus.ImplementedGeneric, profile.EvidenceStatus); + Assert.Null(profile.ExpectedSamplesPerSecond); + Assert.Null(profile.ExpectedSamplesPerCycle); + Assert.Empty(profile.AllowedAsduPerFrame); + Assert.Single(profile.Sources); + } + + private static SvObservedStreamFacts CreateMatchingFacts() + => new() + { + EtherType = 0x88BA, + AppId = 0x4001, + AsduPerFrame = 2, + PayloadBytesPerAsdu = 8, + ObservedSamplesPerSecond = 4000, + ObservedCounterWrap = 4000, + NominalFrequencyHz = 50, + DataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" }, + new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true } + ], + ObservationCount = 100 + }; + + private static SvProfileDefinition CreateSyntheticProfile() + => new() + { + Id = "synthetic-protection-profile", + DisplayName = "Synthetic protection profile", + Family = "Test fixture", + SamplingBasis = SvSamplingBasis.SamplesPerSecond, + ExpectedEtherType = 0x88BA, + AllowedAsduPerFrame = [2], + ExpectedPayloadBytesPerAsdu = 8, + ExpectedDataSetElementCount = 2, + ExpectedDataSetSignature = + [ + new SvDatasetElementSignature { BType = "INT32", Cdc = "SAV" }, + new SvDatasetElementSignature { BType = "Quality", Cdc = "SAV", IsQuality = true } + ], + ExpectedSamplesPerSecond = 4000, + AllowedNominalFrequenciesHz = [50], + ExpectedCounterWrap = 4000, + RateTolerancePercent = 0.5, + EvidenceStatus = SvProfileEvidenceStatus.ResearchCandidate, + Sources = + [ + new SvProfileSourceEvidence( + "test-fixture", + "Synthetic values used only to verify detector behavior.", + SvProfileEvidenceStatus.ResearchCandidate) + ] + }; +}