From 3ccf92e27b52241d87a3634649240f67f1cff332 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 16:19:56 +0700 Subject: [PATCH 01/21] Add versioned declarative SV profile manifest engine --- .../Profiles/SvProfileManifest.cs | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs new file mode 100644 index 0000000..1221cb4 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs @@ -0,0 +1,309 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AR.Iec61850.SampledValues.Profiles; + +public enum SvProfileManifestTrustLevel +{ + UntrustedExternal, + ReviewedEngineering, + TrustedRepository +} + +public sealed record SvProfileManifestLoadOptions +{ + public int MaximumJsonBytes { get; init; } = 1_048_576; + public int MaximumProfiles { get; init; } = 64; + public int MaximumSourcesPerProfile { get; init; } = 16; + public SvProfileManifestTrustLevel TrustLevel { get; init; } = SvProfileManifestTrustLevel.UntrustedExternal; + public bool AllowBuiltInProfileReplacement { get; init; } + + public void Validate() + { + if (MaximumJsonBytes is < 1_024 or > 16_777_216) + throw new InvalidOperationException("SV profile manifest JSON limit must be between 1 KiB and 16 MiB."); + if (MaximumProfiles is < 1 or > 1_024) + throw new InvalidOperationException("SV profile manifest profile limit must be between 1 and 1024."); + if (MaximumSourcesPerProfile is < 1 or > 256) + throw new InvalidOperationException("SV profile manifest source limit must be between 1 and 256."); + if (AllowBuiltInProfileReplacement && TrustLevel != SvProfileManifestTrustLevel.TrustedRepository) + { + throw new InvalidOperationException( + "Built-in profile replacement is allowed only for trusted-repository manifests."); + } + } +} + +public sealed record SvProfileManifestDocument +{ + public const string CurrentSchemaVersion = "arsvin.sv-profile-manifest/v1"; + + public string SchemaVersion { get; init; } = CurrentSchemaVersion; + public string ManifestId { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public DateTimeOffset CreatedAt { get; init; } + public IReadOnlyList Profiles { get; init; } + = Array.Empty(); + + public void Validate(SvProfileManifestLoadOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + + if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal)) + throw new InvalidDataException($"Unsupported SV profile manifest schema '{SchemaVersion}'."); + if (string.IsNullOrWhiteSpace(ManifestId)) + throw new InvalidDataException("SV profile manifest requires a stable manifestId."); + if (!IsSafeIdentifier(ManifestId)) + throw new InvalidDataException("SV profile manifestId may contain only letters, digits, '.', '-', and '_'."); + if (string.IsNullOrWhiteSpace(DisplayName)) + throw new InvalidDataException($"SV profile manifest '{ManifestId}' requires a displayName."); + if (Profiles.Count == 0) + throw new InvalidDataException($"SV profile manifest '{ManifestId}' contains no profiles."); + if (Profiles.Count > options.MaximumProfiles) + { + throw new InvalidDataException( + $"SV profile manifest '{ManifestId}' contains {Profiles.Count} profiles; the configured limit is {options.MaximumProfiles}."); + } + + var duplicateIds = Profiles + .Where(profile => !string.IsNullOrWhiteSpace(profile.Id)) + .GroupBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (duplicateIds.Length > 0) + { + throw new InvalidDataException( + $"SV profile manifest '{ManifestId}' contains duplicate profile IDs: {string.Join(", ", duplicateIds)}."); + } + + foreach (var profile in Profiles) + { + try + { + profile.Validate(); + } + catch (InvalidOperationException ex) + { + throw new InvalidDataException( + $"SV profile manifest '{ManifestId}' profile '{profile.Id}' is invalid: {ex.Message}", + ex); + } + + if (!IsSafeIdentifier(profile.Id)) + throw new InvalidDataException($"SV profile ID '{profile.Id}' contains unsupported characters."); + if (profile.Sources.Count > options.MaximumSourcesPerProfile) + { + throw new InvalidDataException( + $"SV profile '{profile.Id}' contains {profile.Sources.Count} evidence sources; the configured limit is {options.MaximumSourcesPerProfile}."); + } + } + } + + private static bool IsSafeIdentifier(string value) + => value.All(character => char.IsLetterOrDigit(character) || character is '.' or '-' or '_'); +} + +public sealed record SvProfileManifestLoadResult +{ + public SvProfileManifestDocument Document { get; init; } = new(); + public SvProfileManifestTrustLevel TrustLevel { get; init; } + public IReadOnlyList Profiles { get; init; } + = Array.Empty(); + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); +} + +public static class SvProfileManifestSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + public static string ToJson(SvProfileManifestDocument document) + { + ArgumentNullException.ThrowIfNull(document); + var options = new SvProfileManifestLoadOptions + { + TrustLevel = SvProfileManifestTrustLevel.TrustedRepository + }; + document.Validate(options); + return JsonSerializer.Serialize(document, JsonOptions); + } + + public static SvProfileManifestLoadResult FromJson( + string json, + SvProfileManifestLoadOptions? options = null) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("SV profile manifest JSON is empty."); + + options ??= new SvProfileManifestLoadOptions(); + options.Validate(); + var byteCount = Encoding.UTF8.GetByteCount(json); + if (byteCount > options.MaximumJsonBytes) + { + throw new InvalidDataException( + $"SV profile manifest is {byteCount:N0} bytes; the configured limit is {options.MaximumJsonBytes:N0} bytes."); + } + + SvProfileManifestDocument document; + try + { + document = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("SV profile manifest JSON did not contain a document."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"SV profile manifest JSON is invalid: {ex.Message}", ex); + } + + document.Validate(options); + var diagnostics = new List(); + var profiles = document.Profiles + .Select(profile => NormalizeProfile(document, profile, options.TrustLevel, diagnostics)) + .OrderBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new SvProfileManifestLoadResult + { + Document = document, + TrustLevel = options.TrustLevel, + Profiles = profiles, + Diagnostics = diagnostics + }; + } + + private static SvProfileDefinition NormalizeProfile( + SvProfileManifestDocument document, + SvProfileDefinition profile, + SvProfileManifestTrustLevel trustLevel, + ICollection diagnostics) + { + var maximumStatus = MaximumEvidenceStatus(trustLevel); + var normalizedStatus = CapStatus(profile.EvidenceStatus, maximumStatus); + if (normalizedStatus != profile.EvidenceStatus) + { + diagnostics.Add( + $"Profile '{profile.Id}' evidence status was reduced from {profile.EvidenceStatus} to {normalizedStatus} for {trustLevel} trust."); + } + + var normalizedSources = profile.Sources + .Select(source => + { + var status = CapStatus(source.Status, maximumStatus); + if (status != source.Status) + { + diagnostics.Add( + $"Profile '{profile.Id}' source '{source.SourceId}' status was reduced from {source.Status} to {status}."); + } + return source with { Status = status }; + }) + .Append(new SvProfileSourceEvidence( + $"manifest:{document.ManifestId}", + $"Loaded from declarative profile manifest '{document.DisplayName}'.", + normalizedStatus)) + .GroupBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var normalized = profile with + { + EvidenceStatus = normalizedStatus, + Sources = normalizedSources + }; + normalized.Validate(); + return normalized; + } + + private static SvProfileEvidenceStatus MaximumEvidenceStatus(SvProfileManifestTrustLevel trustLevel) + => trustLevel switch + { + SvProfileManifestTrustLevel.UntrustedExternal => SvProfileEvidenceStatus.ResearchCandidate, + SvProfileManifestTrustLevel.ReviewedEngineering => SvProfileEvidenceStatus.ImplementedGeneric, + SvProfileManifestTrustLevel.TrustedRepository => SvProfileEvidenceStatus.VerifiedLab, + _ => SvProfileEvidenceStatus.ResearchCandidate + }; + + private static SvProfileEvidenceStatus CapStatus( + SvProfileEvidenceStatus value, + SvProfileEvidenceStatus maximum) + => EvidenceRank(value) <= EvidenceRank(maximum) ? value : maximum; + + private static int EvidenceRank(SvProfileEvidenceStatus status) + => status switch + { + SvProfileEvidenceStatus.ResearchCandidate => 0, + SvProfileEvidenceStatus.ImplementedGeneric => 1, + SvProfileEvidenceStatus.VerifiedStandard => 2, + SvProfileEvidenceStatus.VerifiedCapture => 3, + SvProfileEvidenceStatus.VerifiedLab => 4, + _ => 0 + }; + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + MaxDepth = 32 + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} + +public static class SvProfileCatalogComposer +{ + public static IReadOnlyList Compose( + IEnumerable builtInProfiles, + IEnumerable manifests, + SvProfileManifestLoadOptions? options = null) + { + ArgumentNullException.ThrowIfNull(builtInProfiles); + ArgumentNullException.ThrowIfNull(manifests); + options ??= new SvProfileManifestLoadOptions(); + options.Validate(); + + var catalog = new Dictionary(StringComparer.OrdinalIgnoreCase); + var builtInIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var profile in builtInProfiles) + { + profile.Validate(); + if (!catalog.TryAdd(profile.Id, profile)) + throw new InvalidOperationException($"Built-in SV profile ID '{profile.Id}' is duplicated."); + builtInIds.Add(profile.Id); + } + + foreach (var manifest in manifests + .OrderBy(item => item.Document.ManifestId, StringComparer.OrdinalIgnoreCase)) + { + foreach (var profile in manifest.Profiles) + { + if (catalog.TryGetValue(profile.Id, out _)) + { + var mayReplace = builtInIds.Contains(profile.Id) && + options.AllowBuiltInProfileReplacement && + manifest.TrustLevel == SvProfileManifestTrustLevel.TrustedRepository; + if (!mayReplace) + { + throw new InvalidDataException( + $"SV profile ID '{profile.Id}' from manifest '{manifest.Document.ManifestId}' collides with an existing catalog profile."); + } + } + + catalog[profile.Id] = profile; + } + } + + return catalog.Values + .OrderBy(profile => builtInIds.Contains(profile.Id) ? 0 : 1) + .ThenBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } +} From 04f32a90f315e8c4bcd437d9e9f507fc52f6a228 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 16:20:44 +0700 Subject: [PATCH 02/21] Add declarative SV profile manifest regression tests --- .../Profiles/SvProfileManifestTests.cs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs new file mode 100644 index 0000000..5ae6658 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs @@ -0,0 +1,188 @@ +using AR.Iec61850.SampledValues.Profiles; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Profiles; + +public sealed class SvProfileManifestTests +{ + [Fact] + public void TrustedManifestRoundTripsAndBuildsDeterministicCatalog() + { + var document = BuildDocument("lab-pack", "profile-lab", SvProfileEvidenceStatus.VerifiedLab); + + var json = SvProfileManifestSerializer.ToJson(document); + var loaded = SvProfileManifestSerializer.FromJson( + json, + new SvProfileManifestLoadOptions + { + TrustLevel = SvProfileManifestTrustLevel.TrustedRepository + }); + var catalog = SvProfileCatalogComposer.Compose( + SvProfileCatalog.BuiltIn, + [loaded], + new SvProfileManifestLoadOptions + { + TrustLevel = SvProfileManifestTrustLevel.TrustedRepository + }); + + Assert.Equal("lab-pack", loaded.Document.ManifestId); + Assert.Empty(loaded.Diagnostics); + Assert.Contains(loaded.Profiles[0].Sources, source => source.SourceId == "manifest:lab-pack"); + Assert.Equal("generic-scl-layer2", catalog[0].Id); + Assert.Equal("profile-lab", catalog[1].Id); + } + + [Fact] + public void UntrustedManifestCannotSelfAssertVerifiedEvidence() + { + var json = SvProfileManifestSerializer.ToJson( + BuildDocument("external-pack", "external-profile", SvProfileEvidenceStatus.VerifiedLab)); + + var loaded = SvProfileManifestSerializer.FromJson(json); + var profile = Assert.Single(loaded.Profiles); + + Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, profile.EvidenceStatus); + Assert.All(profile.Sources, source => + Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, source.Status)); + Assert.Contains(loaded.Diagnostics, diagnostic => + diagnostic.Contains("reduced from VerifiedLab", StringComparison.Ordinal)); + } + + [Fact] + public void ManifestRejectsDuplicateProfileIdsCaseInsensitively() + { + var first = BuildProfile("duplicate", SvProfileEvidenceStatus.ResearchCandidate); + var document = new SvProfileManifestDocument + { + ManifestId = "duplicate-pack", + DisplayName = "Duplicate pack", + Profiles = [first, first with { Id = "DUPLICATE" }] + }; + + var error = Assert.Throws(() => + SvProfileManifestSerializer.ToJson(document)); + + Assert.Contains("duplicate profile IDs", error.Message); + } + + [Fact] + public void ExternalManifestCannotReplaceBuiltInProfile() + { + var document = BuildDocument( + "collision-pack", + SvProfileCatalog.GenericSclLayer2.Id, + SvProfileEvidenceStatus.ResearchCandidate); + var loaded = SvProfileManifestSerializer.FromJson( + SvProfileManifestSerializer.ToJson(document)); + + var error = Assert.Throws(() => + SvProfileCatalogComposer.Compose(SvProfileCatalog.BuiltIn, [loaded])); + + Assert.Contains("collides with an existing catalog profile", error.Message); + } + + [Fact] + public void ManifestProfileCanBeEvaluatedWithoutVendorSpecificLogic() + { + var loaded = SvProfileManifestSerializer.FromJson( + SvProfileManifestSerializer.ToJson( + BuildDocument("candidate-pack", "candidate-4i4u", SvProfileEvidenceStatus.VerifiedCapture))); + var profile = Assert.Single(loaded.Profiles); + var facts = new SvObservedStreamFacts + { + EtherType = 0x88BA, + AsduPerFrame = 1, + PayloadBytesPerAsdu = 64, + ObservedSamplesPerSecond = 4_000, + NominalFrequencyHz = 50, + ObservedCounterWrap = 4_000, + DataSetSignature = BuildSignature() + }; + + var result = new SvProfileDetector().Evaluate(facts, profile); + + Assert.Equal(100, result.ScorePercent); + Assert.Equal(SvProfileConfidence.Confirmed, result.RawConfidence); + Assert.Equal(SvProfileConfidence.Possible, result.Confidence); + Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, result.Profile.EvidenceStatus); + } + + [Fact] + public void ManifestRejectsUnsupportedSchemaAndExcessiveInput() + { + var document = BuildDocument("schema-pack", "schema-profile", SvProfileEvidenceStatus.ResearchCandidate) + with { SchemaVersion = "arsvin.sv-profile-manifest/v999" }; + var json = System.Text.Json.JsonSerializer.Serialize(document); + + var schemaError = Assert.Throws(() => + SvProfileManifestSerializer.FromJson(json)); + Assert.Contains("Unsupported SV profile manifest schema", schemaError.Message); + + var sizeError = Assert.Throws(() => + SvProfileManifestSerializer.FromJson( + new string('x', 2_048), + new SvProfileManifestLoadOptions { MaximumJsonBytes = 1_024 })); + Assert.Contains("configured limit", sizeError.Message); + } + + private static SvProfileManifestDocument BuildDocument( + string manifestId, + string profileId, + SvProfileEvidenceStatus status) + => new() + { + ManifestId = manifestId, + DisplayName = $"Manifest {manifestId}", + Description = "Deterministic test manifest.", + CreatedAt = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.Zero), + Profiles = [BuildProfile(profileId, status)] + }; + + private static SvProfileDefinition BuildProfile( + string id, + SvProfileEvidenceStatus status) + => new() + { + Id = id, + DisplayName = $"Profile {id}", + Family = "Fixed 4I + 4V candidate", + SamplingBasis = SvSamplingBasis.SamplesPerCycle, + ExpectedEtherType = 0x88BA, + AllowedAsduPerFrame = [1], + ExpectedPayloadBytesPerAsdu = 64, + ExpectedDataSetElementCount = 16, + ExpectedDataSetSignature = BuildSignature(), + ExpectedSamplesPerCycle = 80, + AllowedNominalFrequenciesHz = [50, 60], + ExpectedCounterWrap = 4_000, + RateTolerancePercent = 1, + EvidenceStatus = status, + Sources = + [ + new SvProfileSourceEvidence( + "test-evidence", + "Deterministic regression evidence for manifest parsing.", + status) + ] + }; + + private static IReadOnlyList BuildSignature() + { + var signature = new List(); + for (var index = 0; index < 8; index++) + { + signature.Add(new SvDatasetElementSignature + { + BType = "INT32", + Cdc = index < 4 ? "SAV-Current" : "SAV-Voltage" + }); + signature.Add(new SvDatasetElementSignature + { + BType = "Quality", + Cdc = index < 4 ? "SAV-Current" : "SAV-Voltage", + IsQuality = true + }); + } + return signature; + } +} From 145f2b475fb0feb66e74ddf05cc6fef791225dcd Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 16:21:22 +0700 Subject: [PATCH 03/21] Add safe declarative profile manifest example --- .../profile-manifest-v1-example.json | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 samples/sv-evidence/profile-manifest-v1-example.json diff --git a/samples/sv-evidence/profile-manifest-v1-example.json b/samples/sv-evidence/profile-manifest-v1-example.json new file mode 100644 index 0000000..41950ab --- /dev/null +++ b/samples/sv-evidence/profile-manifest-v1-example.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": "arsvin.sv-profile-manifest/v1", + "manifestId": "example-fixed-layouts", + "displayName": "Example fixed-layout research profiles", + "description": "Template only. Replace the evidence metadata and expectations with reviewed source-backed values before engineering use.", + "createdAt": "2026-07-22T10:00:00Z", + "profiles": [ + { + "id": "example-fixed-4i4v-50hz", + "displayName": "Example fixed 4I + 4V at 50 Hz", + "family": "Example fixed-layout SV", + "samplingBasis": "samplesPerCycle", + "expectedEtherType": 35002, + "allowedAsduPerFrame": [1], + "expectedPayloadBytesPerAsdu": 64, + "expectedDataSetElementCount": 16, + "expectedDataSetSignature": [ + { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, + { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, + { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false } + ], + "expectedSamplesPerCycle": 80, + "expectedSamplesPerSecond": null, + "allowedNominalFrequenciesHz": [50], + "expectedCounterWrap": 4000, + "rateTolerancePercent": 1.0, + "evidenceStatus": "researchCandidate", + "sources": [ + { + "sourceId": "replace-with-reviewed-source", + "description": "Template evidence entry. Replace with an official document, reviewed capture, or controlled laboratory record.", + "status": "researchCandidate" + } + ] + } + ] +} From 5a1634bededcf1e747e6bcc55c0278703c4e2535 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 16:21:56 +0700 Subject: [PATCH 04/21] Document declarative SV profile manifest safety model --- docs/sv-profile-manifests.md | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/sv-profile-manifests.md diff --git a/docs/sv-profile-manifests.md b/docs/sv-profile-manifests.md new file mode 100644 index 0000000..5b10448 --- /dev/null +++ b/docs/sv-profile-manifests.md @@ -0,0 +1,94 @@ +# Declarative Sampled Values profile manifests + +ARSVIN profile manifests are versioned JSON evidence documents that describe observable Sampled Values stream expectations without embedding vendor-specific branches in the decoder. + +The first schema is: + +```text +arsvin.sv-profile-manifest/v1 +``` + +A manifest can describe: + +- EtherType, +- allowed `noASDU` values, +- payload length per ASDU, +- ordered dataset signature, +- samples per cycle or samples per second, +- allowed nominal frequencies, +- expected sample-counter wrap, +- comparison tolerance, +- evidence maturity, +- reviewable evidence sources. + +## Safety model + +Loading a profile definition is not the same as proving conformance or interoperability. + +Every load has an explicit trust level: + +| Trust level | Maximum accepted evidence maturity | +|---|---| +| `UntrustedExternal` | `ResearchCandidate` | +| `ReviewedEngineering` | `ImplementedGeneric` | +| `TrustedRepository` | `VerifiedLab` | + +A profile from an untrusted external JSON file cannot self-promote itself to `VerifiedStandard`, `VerifiedCapture`, or `VerifiedLab`. ARSVIN reduces the status and records a diagnostic. This also limits the maximum profile confidence through the existing evidence-maturity policy. + +Built-in profile IDs cannot be replaced by an external manifest. Replacement is possible only when both conditions are explicit: + +1. the load is `TrustedRepository`, and +2. `AllowBuiltInProfileReplacement` is enabled. + +## Resource limits + +The loader validates limits before adding profiles to a catalog: + +- JSON size, +- profile count, +- evidence-source count, +- unique profile IDs, +- safe identifiers, +- sampling consistency, +- dataset count and ordered signature consistency, +- positive rates, frequencies, payload sizes, and counter wraps. + +The default limits are deliberately conservative: 1 MiB, 64 profiles, and 16 evidence sources per profile. + +## Deterministic catalog composition + +`SvProfileCatalogComposer` combines built-in definitions and loaded manifests using case-insensitive stable IDs. The result is deterministic: + +1. built-in profiles first, +2. external profiles sorted by ID. + +A collision fails closed unless the trusted replacement policy is explicitly enabled. + +## Detection behavior + +Manifest profiles use the same `SvProfileDetector` as built-in profiles. There is no alternate vendor decoder. Detection remains based on observed evidence: + +```text +observed wire facts ++ calculated capture facts ++ SCL-derived signature ++ trusted frequency context +→ weighted profile evidence +→ confidence limited by evidence maturity +``` + +Device identity, source MAC, product name, and vendor name are evidence metadata, not permission to assume payload layout or scaling. + +## Example + +A safe research template is provided at: + +```text +samples/sv-evidence/profile-manifest-v1-example.json +``` + +It is intentionally marked `ResearchCandidate`. Replace the placeholder source with reviewed official documentation, sanitized SCL, byte-exact PCAP fixtures, or controlled laboratory evidence before using it for engineering classification. + +## Current integration boundary + +This phase provides the versioned parser, validation, trust controls, deterministic catalog composer, example manifest, and regression tests. Runtime import in ArSubsv and repository-managed 9-2LE or IEC 61869-9 fixture packs remain separate acceptance steps. This prevents an unreviewed JSON file from silently changing live decoding or measurement behavior. From 1bacb767a342a5840eda9f3d4f68a7ea405c46a9 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:07:58 +0700 Subject: [PATCH 05/21] Harden generic SV scaling evidence --- .../Measurements/SvEngineeringScaling.cs | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs index 0e4d91f..2d5fc8a 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -29,7 +29,13 @@ public sealed record SvEngineeringScale public enum SvEngineeringScaleSource { RawOnly, + + /// + /// Retained for evidence-document compatibility. Generic receive-side analysis no longer activates + /// engineering scaling from packet shape or observed rate alone. + /// Legacy92LeStyleStructuralInference, + SclBackedLegacy92LeStyle, ManualOverride } @@ -60,8 +66,9 @@ public sealed record SvEngineeringScaleEvidence } /// -/// Resolves conservative engineering scaling for installed-base 9-2LE-style protection streams. -/// The resolver requires structural evidence plus sampling or SCL evidence. Otherwise it returns raw counts. +/// Resolves conservative engineering scaling for an explicitly SCL-bound installed-base +/// 4-current/4-voltage protection layout. Packet shape, rate, product name, APPID, MAC address, +/// svID, and amplitude are never sufficient by themselves. /// public static class SvEngineeringScaleResolver { @@ -75,27 +82,31 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) var domain = ResolveDomain(evidence.Channel, evidence.Kind); if (domain == SvMeasurementDomain.Unknown) - return SvEngineeringScale.RawOnly("The channel could not be classified as current or voltage."); + return SvEngineeringScale.RawOnly("The SCL-derived channel semantics do not prove a current or voltage domain."); + + if (!evidence.IsSclBound) + { + return SvEngineeringScale.RawOnly( + "No SCL dataset mapping is bound. Generic packet inspection preserves raw counts and does not infer engineering units."); + } var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && evidence.AnalogChannelCount == 8 && evidence.PayloadBytesPerAsdu == 64; if (!fixedLayout) - return SvEngineeringScale.RawOnly("The payload is not proven to be the fixed 4I+4U value-quality layout."); - - var samplingEvidence = HasProtectionRateEvidence(evidence); - if (!evidence.IsSclBound && !samplingEvidence) - return SvEngineeringScale.RawOnly("The fixed layout is visible, but sampling or SCL evidence is insufficient for engineering scaling."); - - var source = evidence.IsSclBound - ? SvEngineeringScaleSource.SclBackedLegacy92LeStyle - : SvEngineeringScaleSource.Legacy92LeStyleStructuralInference; - var confidence = evidence.IsSclBound - ? SvEngineeringScaleConfidence.SclBacked - : SvEngineeringScaleConfidence.Inferred; - var reason = evidence.IsSclBound - ? "SCL binding and fixed 4I+4U structural evidence support installed-base 9-2LE-style scaling." - : "Fixed 4I+4U structure and protection-rate evidence support provisional 9-2LE-style scaling."; + { + return SvEngineeringScale.RawOnly( + "The SCL-mapped payload is not the fixed 4-current/4-voltage value-quality layout required by this scaling rule."); + } + + if (!HasProtectionRateEvidence(evidence)) + { + return SvEngineeringScale.RawOnly( + "The fixed SCL layout is visible, but declared or observed protection-rate evidence is insufficient for the installed-base scaling rule."); + } + + var reason = + "SCL dataset mapping, fixed 4-current/4-voltage structure, and protection-rate evidence support the installed-base 9-2LE-style scale."; return domain switch { @@ -103,16 +114,16 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) { Multiplier = CurrentAmperesPerCount, Unit = "A", - Source = source, - Confidence = confidence, + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, Reason = reason }, SvMeasurementDomain.Voltage => new SvEngineeringScale { Multiplier = VoltageVoltsPerCount, Unit = "V", - Source = source, - Confidence = confidence, + Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, + Confidence = SvEngineeringScaleConfidence.SclBacked, Reason = reason }, _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") From 3f0529c4326258ef7a6a82d9b06e3ab8059fd8f0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:08:40 +0700 Subject: [PATCH 06/21] Remove profile-manifest direction --- docs/sv-profile-manifests.md | 94 ------------------------------------ 1 file changed, 94 deletions(-) delete mode 100644 docs/sv-profile-manifests.md diff --git a/docs/sv-profile-manifests.md b/docs/sv-profile-manifests.md deleted file mode 100644 index 5b10448..0000000 --- a/docs/sv-profile-manifests.md +++ /dev/null @@ -1,94 +0,0 @@ -# Declarative Sampled Values profile manifests - -ARSVIN profile manifests are versioned JSON evidence documents that describe observable Sampled Values stream expectations without embedding vendor-specific branches in the decoder. - -The first schema is: - -```text -arsvin.sv-profile-manifest/v1 -``` - -A manifest can describe: - -- EtherType, -- allowed `noASDU` values, -- payload length per ASDU, -- ordered dataset signature, -- samples per cycle or samples per second, -- allowed nominal frequencies, -- expected sample-counter wrap, -- comparison tolerance, -- evidence maturity, -- reviewable evidence sources. - -## Safety model - -Loading a profile definition is not the same as proving conformance or interoperability. - -Every load has an explicit trust level: - -| Trust level | Maximum accepted evidence maturity | -|---|---| -| `UntrustedExternal` | `ResearchCandidate` | -| `ReviewedEngineering` | `ImplementedGeneric` | -| `TrustedRepository` | `VerifiedLab` | - -A profile from an untrusted external JSON file cannot self-promote itself to `VerifiedStandard`, `VerifiedCapture`, or `VerifiedLab`. ARSVIN reduces the status and records a diagnostic. This also limits the maximum profile confidence through the existing evidence-maturity policy. - -Built-in profile IDs cannot be replaced by an external manifest. Replacement is possible only when both conditions are explicit: - -1. the load is `TrustedRepository`, and -2. `AllowBuiltInProfileReplacement` is enabled. - -## Resource limits - -The loader validates limits before adding profiles to a catalog: - -- JSON size, -- profile count, -- evidence-source count, -- unique profile IDs, -- safe identifiers, -- sampling consistency, -- dataset count and ordered signature consistency, -- positive rates, frequencies, payload sizes, and counter wraps. - -The default limits are deliberately conservative: 1 MiB, 64 profiles, and 16 evidence sources per profile. - -## Deterministic catalog composition - -`SvProfileCatalogComposer` combines built-in definitions and loaded manifests using case-insensitive stable IDs. The result is deterministic: - -1. built-in profiles first, -2. external profiles sorted by ID. - -A collision fails closed unless the trusted replacement policy is explicitly enabled. - -## Detection behavior - -Manifest profiles use the same `SvProfileDetector` as built-in profiles. There is no alternate vendor decoder. Detection remains based on observed evidence: - -```text -observed wire facts -+ calculated capture facts -+ SCL-derived signature -+ trusted frequency context -→ weighted profile evidence -→ confidence limited by evidence maturity -``` - -Device identity, source MAC, product name, and vendor name are evidence metadata, not permission to assume payload layout or scaling. - -## Example - -A safe research template is provided at: - -```text -samples/sv-evidence/profile-manifest-v1-example.json -``` - -It is intentionally marked `ResearchCandidate`. Replace the placeholder source with reviewed official documentation, sanitized SCL, byte-exact PCAP fixtures, or controlled laboratory evidence before using it for engineering classification. - -## Current integration boundary - -This phase provides the versioned parser, validation, trust controls, deterministic catalog composer, example manifest, and regression tests. Runtime import in ArSubsv and repository-managed 9-2LE or IEC 61869-9 fixture packs remain separate acceptance steps. This prevents an unreviewed JSON file from silently changing live decoding or measurement behavior. From 98cddbb70c17630821a429f1636f586eb354b121 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:08:47 +0700 Subject: [PATCH 07/21] Remove profile-manifest example --- .../profile-manifest-v1-example.json | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 samples/sv-evidence/profile-manifest-v1-example.json diff --git a/samples/sv-evidence/profile-manifest-v1-example.json b/samples/sv-evidence/profile-manifest-v1-example.json deleted file mode 100644 index 41950ab..0000000 --- a/samples/sv-evidence/profile-manifest-v1-example.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "schemaVersion": "arsvin.sv-profile-manifest/v1", - "manifestId": "example-fixed-layouts", - "displayName": "Example fixed-layout research profiles", - "description": "Template only. Replace the evidence metadata and expectations with reviewed source-backed values before engineering use.", - "createdAt": "2026-07-22T10:00:00Z", - "profiles": [ - { - "id": "example-fixed-4i4v-50hz", - "displayName": "Example fixed 4I + 4V at 50 Hz", - "family": "Example fixed-layout SV", - "samplingBasis": "samplesPerCycle", - "expectedEtherType": 35002, - "allowedAsduPerFrame": [1], - "expectedPayloadBytesPerAsdu": 64, - "expectedDataSetElementCount": 16, - "expectedDataSetSignature": [ - { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Current", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Current", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false }, - { "bType": "INT32", "cdc": "SAV-Voltage", "isQuality": false, "isTimestamp": false }, - { "bType": "Quality", "cdc": "SAV-Voltage", "isQuality": true, "isTimestamp": false } - ], - "expectedSamplesPerCycle": 80, - "expectedSamplesPerSecond": null, - "allowedNominalFrequenciesHz": [50], - "expectedCounterWrap": 4000, - "rateTolerancePercent": 1.0, - "evidenceStatus": "researchCandidate", - "sources": [ - { - "sourceId": "replace-with-reviewed-source", - "description": "Template evidence entry. Replace with an official document, reviewed capture, or controlled laboratory record.", - "status": "researchCandidate" - } - ] - } - ] -} From 24c267c4f0ee797e38bfbe6b2cc841eeed2d7145 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:08:54 +0700 Subject: [PATCH 08/21] Remove declarative profile manifest engine --- .../Profiles/SvProfileManifest.cs | 309 ------------------ 1 file changed, 309 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs deleted file mode 100644 index 1221cb4..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Profiles/SvProfileManifest.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace AR.Iec61850.SampledValues.Profiles; - -public enum SvProfileManifestTrustLevel -{ - UntrustedExternal, - ReviewedEngineering, - TrustedRepository -} - -public sealed record SvProfileManifestLoadOptions -{ - public int MaximumJsonBytes { get; init; } = 1_048_576; - public int MaximumProfiles { get; init; } = 64; - public int MaximumSourcesPerProfile { get; init; } = 16; - public SvProfileManifestTrustLevel TrustLevel { get; init; } = SvProfileManifestTrustLevel.UntrustedExternal; - public bool AllowBuiltInProfileReplacement { get; init; } - - public void Validate() - { - if (MaximumJsonBytes is < 1_024 or > 16_777_216) - throw new InvalidOperationException("SV profile manifest JSON limit must be between 1 KiB and 16 MiB."); - if (MaximumProfiles is < 1 or > 1_024) - throw new InvalidOperationException("SV profile manifest profile limit must be between 1 and 1024."); - if (MaximumSourcesPerProfile is < 1 or > 256) - throw new InvalidOperationException("SV profile manifest source limit must be between 1 and 256."); - if (AllowBuiltInProfileReplacement && TrustLevel != SvProfileManifestTrustLevel.TrustedRepository) - { - throw new InvalidOperationException( - "Built-in profile replacement is allowed only for trusted-repository manifests."); - } - } -} - -public sealed record SvProfileManifestDocument -{ - public const string CurrentSchemaVersion = "arsvin.sv-profile-manifest/v1"; - - public string SchemaVersion { get; init; } = CurrentSchemaVersion; - public string ManifestId { get; init; } = string.Empty; - public string DisplayName { get; init; } = string.Empty; - public string Description { get; init; } = string.Empty; - public DateTimeOffset CreatedAt { get; init; } - public IReadOnlyList Profiles { get; init; } - = Array.Empty(); - - public void Validate(SvProfileManifestLoadOptions options) - { - ArgumentNullException.ThrowIfNull(options); - options.Validate(); - - if (!string.Equals(SchemaVersion, CurrentSchemaVersion, StringComparison.Ordinal)) - throw new InvalidDataException($"Unsupported SV profile manifest schema '{SchemaVersion}'."); - if (string.IsNullOrWhiteSpace(ManifestId)) - throw new InvalidDataException("SV profile manifest requires a stable manifestId."); - if (!IsSafeIdentifier(ManifestId)) - throw new InvalidDataException("SV profile manifestId may contain only letters, digits, '.', '-', and '_'."); - if (string.IsNullOrWhiteSpace(DisplayName)) - throw new InvalidDataException($"SV profile manifest '{ManifestId}' requires a displayName."); - if (Profiles.Count == 0) - throw new InvalidDataException($"SV profile manifest '{ManifestId}' contains no profiles."); - if (Profiles.Count > options.MaximumProfiles) - { - throw new InvalidDataException( - $"SV profile manifest '{ManifestId}' contains {Profiles.Count} profiles; the configured limit is {options.MaximumProfiles}."); - } - - var duplicateIds = Profiles - .Where(profile => !string.IsNullOrWhiteSpace(profile.Id)) - .GroupBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) - .Where(group => group.Count() > 1) - .Select(group => group.Key) - .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (duplicateIds.Length > 0) - { - throw new InvalidDataException( - $"SV profile manifest '{ManifestId}' contains duplicate profile IDs: {string.Join(", ", duplicateIds)}."); - } - - foreach (var profile in Profiles) - { - try - { - profile.Validate(); - } - catch (InvalidOperationException ex) - { - throw new InvalidDataException( - $"SV profile manifest '{ManifestId}' profile '{profile.Id}' is invalid: {ex.Message}", - ex); - } - - if (!IsSafeIdentifier(profile.Id)) - throw new InvalidDataException($"SV profile ID '{profile.Id}' contains unsupported characters."); - if (profile.Sources.Count > options.MaximumSourcesPerProfile) - { - throw new InvalidDataException( - $"SV profile '{profile.Id}' contains {profile.Sources.Count} evidence sources; the configured limit is {options.MaximumSourcesPerProfile}."); - } - } - } - - private static bool IsSafeIdentifier(string value) - => value.All(character => char.IsLetterOrDigit(character) || character is '.' or '-' or '_'); -} - -public sealed record SvProfileManifestLoadResult -{ - public SvProfileManifestDocument Document { get; init; } = new(); - public SvProfileManifestTrustLevel TrustLevel { get; init; } - public IReadOnlyList Profiles { get; init; } - = Array.Empty(); - public IReadOnlyList Diagnostics { get; init; } - = Array.Empty(); -} - -public static class SvProfileManifestSerializer -{ - private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); - - public static string ToJson(SvProfileManifestDocument document) - { - ArgumentNullException.ThrowIfNull(document); - var options = new SvProfileManifestLoadOptions - { - TrustLevel = SvProfileManifestTrustLevel.TrustedRepository - }; - document.Validate(options); - return JsonSerializer.Serialize(document, JsonOptions); - } - - public static SvProfileManifestLoadResult FromJson( - string json, - SvProfileManifestLoadOptions? options = null) - { - if (string.IsNullOrWhiteSpace(json)) - throw new InvalidDataException("SV profile manifest JSON is empty."); - - options ??= new SvProfileManifestLoadOptions(); - options.Validate(); - var byteCount = Encoding.UTF8.GetByteCount(json); - if (byteCount > options.MaximumJsonBytes) - { - throw new InvalidDataException( - $"SV profile manifest is {byteCount:N0} bytes; the configured limit is {options.MaximumJsonBytes:N0} bytes."); - } - - SvProfileManifestDocument document; - try - { - document = JsonSerializer.Deserialize(json, JsonOptions) - ?? throw new InvalidDataException("SV profile manifest JSON did not contain a document."); - } - catch (JsonException ex) - { - throw new InvalidDataException($"SV profile manifest JSON is invalid: {ex.Message}", ex); - } - - document.Validate(options); - var diagnostics = new List(); - var profiles = document.Profiles - .Select(profile => NormalizeProfile(document, profile, options.TrustLevel, diagnostics)) - .OrderBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - return new SvProfileManifestLoadResult - { - Document = document, - TrustLevel = options.TrustLevel, - Profiles = profiles, - Diagnostics = diagnostics - }; - } - - private static SvProfileDefinition NormalizeProfile( - SvProfileManifestDocument document, - SvProfileDefinition profile, - SvProfileManifestTrustLevel trustLevel, - ICollection diagnostics) - { - var maximumStatus = MaximumEvidenceStatus(trustLevel); - var normalizedStatus = CapStatus(profile.EvidenceStatus, maximumStatus); - if (normalizedStatus != profile.EvidenceStatus) - { - diagnostics.Add( - $"Profile '{profile.Id}' evidence status was reduced from {profile.EvidenceStatus} to {normalizedStatus} for {trustLevel} trust."); - } - - var normalizedSources = profile.Sources - .Select(source => - { - var status = CapStatus(source.Status, maximumStatus); - if (status != source.Status) - { - diagnostics.Add( - $"Profile '{profile.Id}' source '{source.SourceId}' status was reduced from {source.Status} to {status}."); - } - return source with { Status = status }; - }) - .Append(new SvProfileSourceEvidence( - $"manifest:{document.ManifestId}", - $"Loaded from declarative profile manifest '{document.DisplayName}'.", - normalizedStatus)) - .GroupBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(source => source.SourceId, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var normalized = profile with - { - EvidenceStatus = normalizedStatus, - Sources = normalizedSources - }; - normalized.Validate(); - return normalized; - } - - private static SvProfileEvidenceStatus MaximumEvidenceStatus(SvProfileManifestTrustLevel trustLevel) - => trustLevel switch - { - SvProfileManifestTrustLevel.UntrustedExternal => SvProfileEvidenceStatus.ResearchCandidate, - SvProfileManifestTrustLevel.ReviewedEngineering => SvProfileEvidenceStatus.ImplementedGeneric, - SvProfileManifestTrustLevel.TrustedRepository => SvProfileEvidenceStatus.VerifiedLab, - _ => SvProfileEvidenceStatus.ResearchCandidate - }; - - private static SvProfileEvidenceStatus CapStatus( - SvProfileEvidenceStatus value, - SvProfileEvidenceStatus maximum) - => EvidenceRank(value) <= EvidenceRank(maximum) ? value : maximum; - - private static int EvidenceRank(SvProfileEvidenceStatus status) - => status switch - { - SvProfileEvidenceStatus.ResearchCandidate => 0, - SvProfileEvidenceStatus.ImplementedGeneric => 1, - SvProfileEvidenceStatus.VerifiedStandard => 2, - SvProfileEvidenceStatus.VerifiedCapture => 3, - SvProfileEvidenceStatus.VerifiedLab => 4, - _ => 0 - }; - - private static JsonSerializerOptions CreateJsonOptions() - { - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - WriteIndented = true, - MaxDepth = 32 - }; - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - return options; - } -} - -public static class SvProfileCatalogComposer -{ - public static IReadOnlyList Compose( - IEnumerable builtInProfiles, - IEnumerable manifests, - SvProfileManifestLoadOptions? options = null) - { - ArgumentNullException.ThrowIfNull(builtInProfiles); - ArgumentNullException.ThrowIfNull(manifests); - options ??= new SvProfileManifestLoadOptions(); - options.Validate(); - - var catalog = new Dictionary(StringComparer.OrdinalIgnoreCase); - var builtInIds = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var profile in builtInProfiles) - { - profile.Validate(); - if (!catalog.TryAdd(profile.Id, profile)) - throw new InvalidOperationException($"Built-in SV profile ID '{profile.Id}' is duplicated."); - builtInIds.Add(profile.Id); - } - - foreach (var manifest in manifests - .OrderBy(item => item.Document.ManifestId, StringComparer.OrdinalIgnoreCase)) - { - foreach (var profile in manifest.Profiles) - { - if (catalog.TryGetValue(profile.Id, out _)) - { - var mayReplace = builtInIds.Contains(profile.Id) && - options.AllowBuiltInProfileReplacement && - manifest.TrustLevel == SvProfileManifestTrustLevel.TrustedRepository; - if (!mayReplace) - { - throw new InvalidDataException( - $"SV profile ID '{profile.Id}' from manifest '{manifest.Document.ManifestId}' collides with an existing catalog profile."); - } - } - - catalog[profile.Id] = profile; - } - } - - return catalog.Values - .OrderBy(profile => builtInIds.Contains(profile.Id) ? 0 : 1) - .ThenBy(profile => profile.Id, StringComparer.OrdinalIgnoreCase) - .ToArray(); - } -} From ee9cd838ecffb1d06b407df25f7dcb09a0920739 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:09:01 +0700 Subject: [PATCH 09/21] Remove profile-manifest tests --- .../Profiles/SvProfileManifestTests.cs | 188 ------------------ 1 file changed, 188 deletions(-) delete mode 100644 tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs b/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs deleted file mode 100644 index 5ae6658..0000000 --- a/tests/ARSVIN.Tests/SampledValues/Profiles/SvProfileManifestTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using AR.Iec61850.SampledValues.Profiles; -using Xunit; - -namespace ARSVIN.Tests.SampledValues.Profiles; - -public sealed class SvProfileManifestTests -{ - [Fact] - public void TrustedManifestRoundTripsAndBuildsDeterministicCatalog() - { - var document = BuildDocument("lab-pack", "profile-lab", SvProfileEvidenceStatus.VerifiedLab); - - var json = SvProfileManifestSerializer.ToJson(document); - var loaded = SvProfileManifestSerializer.FromJson( - json, - new SvProfileManifestLoadOptions - { - TrustLevel = SvProfileManifestTrustLevel.TrustedRepository - }); - var catalog = SvProfileCatalogComposer.Compose( - SvProfileCatalog.BuiltIn, - [loaded], - new SvProfileManifestLoadOptions - { - TrustLevel = SvProfileManifestTrustLevel.TrustedRepository - }); - - Assert.Equal("lab-pack", loaded.Document.ManifestId); - Assert.Empty(loaded.Diagnostics); - Assert.Contains(loaded.Profiles[0].Sources, source => source.SourceId == "manifest:lab-pack"); - Assert.Equal("generic-scl-layer2", catalog[0].Id); - Assert.Equal("profile-lab", catalog[1].Id); - } - - [Fact] - public void UntrustedManifestCannotSelfAssertVerifiedEvidence() - { - var json = SvProfileManifestSerializer.ToJson( - BuildDocument("external-pack", "external-profile", SvProfileEvidenceStatus.VerifiedLab)); - - var loaded = SvProfileManifestSerializer.FromJson(json); - var profile = Assert.Single(loaded.Profiles); - - Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, profile.EvidenceStatus); - Assert.All(profile.Sources, source => - Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, source.Status)); - Assert.Contains(loaded.Diagnostics, diagnostic => - diagnostic.Contains("reduced from VerifiedLab", StringComparison.Ordinal)); - } - - [Fact] - public void ManifestRejectsDuplicateProfileIdsCaseInsensitively() - { - var first = BuildProfile("duplicate", SvProfileEvidenceStatus.ResearchCandidate); - var document = new SvProfileManifestDocument - { - ManifestId = "duplicate-pack", - DisplayName = "Duplicate pack", - Profiles = [first, first with { Id = "DUPLICATE" }] - }; - - var error = Assert.Throws(() => - SvProfileManifestSerializer.ToJson(document)); - - Assert.Contains("duplicate profile IDs", error.Message); - } - - [Fact] - public void ExternalManifestCannotReplaceBuiltInProfile() - { - var document = BuildDocument( - "collision-pack", - SvProfileCatalog.GenericSclLayer2.Id, - SvProfileEvidenceStatus.ResearchCandidate); - var loaded = SvProfileManifestSerializer.FromJson( - SvProfileManifestSerializer.ToJson(document)); - - var error = Assert.Throws(() => - SvProfileCatalogComposer.Compose(SvProfileCatalog.BuiltIn, [loaded])); - - Assert.Contains("collides with an existing catalog profile", error.Message); - } - - [Fact] - public void ManifestProfileCanBeEvaluatedWithoutVendorSpecificLogic() - { - var loaded = SvProfileManifestSerializer.FromJson( - SvProfileManifestSerializer.ToJson( - BuildDocument("candidate-pack", "candidate-4i4u", SvProfileEvidenceStatus.VerifiedCapture))); - var profile = Assert.Single(loaded.Profiles); - var facts = new SvObservedStreamFacts - { - EtherType = 0x88BA, - AsduPerFrame = 1, - PayloadBytesPerAsdu = 64, - ObservedSamplesPerSecond = 4_000, - NominalFrequencyHz = 50, - ObservedCounterWrap = 4_000, - DataSetSignature = BuildSignature() - }; - - var result = new SvProfileDetector().Evaluate(facts, profile); - - Assert.Equal(100, result.ScorePercent); - Assert.Equal(SvProfileConfidence.Confirmed, result.RawConfidence); - Assert.Equal(SvProfileConfidence.Possible, result.Confidence); - Assert.Equal(SvProfileEvidenceStatus.ResearchCandidate, result.Profile.EvidenceStatus); - } - - [Fact] - public void ManifestRejectsUnsupportedSchemaAndExcessiveInput() - { - var document = BuildDocument("schema-pack", "schema-profile", SvProfileEvidenceStatus.ResearchCandidate) - with { SchemaVersion = "arsvin.sv-profile-manifest/v999" }; - var json = System.Text.Json.JsonSerializer.Serialize(document); - - var schemaError = Assert.Throws(() => - SvProfileManifestSerializer.FromJson(json)); - Assert.Contains("Unsupported SV profile manifest schema", schemaError.Message); - - var sizeError = Assert.Throws(() => - SvProfileManifestSerializer.FromJson( - new string('x', 2_048), - new SvProfileManifestLoadOptions { MaximumJsonBytes = 1_024 })); - Assert.Contains("configured limit", sizeError.Message); - } - - private static SvProfileManifestDocument BuildDocument( - string manifestId, - string profileId, - SvProfileEvidenceStatus status) - => new() - { - ManifestId = manifestId, - DisplayName = $"Manifest {manifestId}", - Description = "Deterministic test manifest.", - CreatedAt = new DateTimeOffset(2026, 7, 22, 10, 0, 0, TimeSpan.Zero), - Profiles = [BuildProfile(profileId, status)] - }; - - private static SvProfileDefinition BuildProfile( - string id, - SvProfileEvidenceStatus status) - => new() - { - Id = id, - DisplayName = $"Profile {id}", - Family = "Fixed 4I + 4V candidate", - SamplingBasis = SvSamplingBasis.SamplesPerCycle, - ExpectedEtherType = 0x88BA, - AllowedAsduPerFrame = [1], - ExpectedPayloadBytesPerAsdu = 64, - ExpectedDataSetElementCount = 16, - ExpectedDataSetSignature = BuildSignature(), - ExpectedSamplesPerCycle = 80, - AllowedNominalFrequenciesHz = [50, 60], - ExpectedCounterWrap = 4_000, - RateTolerancePercent = 1, - EvidenceStatus = status, - Sources = - [ - new SvProfileSourceEvidence( - "test-evidence", - "Deterministic regression evidence for manifest parsing.", - status) - ] - }; - - private static IReadOnlyList BuildSignature() - { - var signature = new List(); - for (var index = 0; index < 8; index++) - { - signature.Add(new SvDatasetElementSignature - { - BType = "INT32", - Cdc = index < 4 ? "SAV-Current" : "SAV-Voltage" - }); - signature.Add(new SvDatasetElementSignature - { - BType = "Quality", - Cdc = index < 4 ? "SAV-Current" : "SAV-Voltage", - IsQuality = true - }); - } - return signature; - } -} From 8eefbef6a1ab89621cb1f76d757a8c28e77c5ab1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:09:40 +0700 Subject: [PATCH 10/21] Add generic SV seqOfData inspector --- .../Analysis/SvGenericPayloadInspector.cs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs new file mode 100644 index 0000000..7ff3642 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs @@ -0,0 +1,162 @@ +using System.Buffers.Binary; + +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Describes only the structural position of a 32-bit word in seqOfData. +/// These roles do not assert channel semantics, engineering units, or IEC 61850 quality meaning. +/// +public enum SvGenericPayloadWordRole +{ + StandaloneWord, + FirstWordInEightByteGroup, + SecondWordInEightByteGroup +} + +/// +/// One four-byte big-endian word from an SV sample payload, exposed through multiple numeric views. +/// The views are representations of the same bytes and are not automatic semantic interpretations. +/// +public sealed record SvGenericPayloadWord +{ + public int Index { get; init; } + public int ByteOffset { get; init; } + public SvGenericPayloadWordRole StructuralRole { get; init; } + public byte[] RawBytes { get; init; } = []; + public string Hex { get; init; } = string.Empty; + public int SignedInt32 { get; init; } + public uint UnsignedInt32 { get; init; } + public float Float32 { get; init; } + public bool IsFiniteFloat32 => float.IsFinite(Float32); + + public string GenericLabel => $"Word {Index + 1}"; + public string OffsetLabel => $"+0x{ByteOffset:X2}"; +} + +/// +/// Vendor-neutral structural inspection of one ASDU seqOfData payload. +/// It deliberately preserves unknown semantics instead of inventing current/voltage channels. +/// +public sealed record SvGenericPayloadInspection +{ + public int PayloadLength { get; init; } + public int CompleteWordCount { get; init; } + public int TrailingByteCount { get; init; } + public bool IsFourByteAligned { get; init; } + public bool HasEightByteGroupShape { get; init; } + public IReadOnlyList Words { get; init; } + = Array.Empty(); + public byte[] TrailingBytes { get; init; } = []; + public IReadOnlyList Diagnostics { get; init; } + = Array.Empty(); + + public string Summary + { + get + { + if (PayloadLength == 0) + return "Empty seqOfData payload"; + + var grouping = HasEightByteGroupShape + ? $"{PayloadLength / 8} structural 8-byte group(s)" + : $"{CompleteWordCount} complete 32-bit word(s)"; + return TrailingByteCount == 0 + ? $"Raw generic inspection · {PayloadLength} bytes · {grouping}" + : $"Raw generic inspection · {PayloadLength} bytes · {grouping} · {TrailingByteCount} trailing byte(s)"; + } + } +} + +/// +/// Generic seqOfData inspector used when no trusted dataset layout is available. +/// It never labels words as Ia/Ib/Ic, voltage, current, quality, primary, secondary, A, or V. +/// +public static class SvGenericPayloadInspector +{ + private const int WordBytes = 4; + private const int StructuralGroupBytes = 8; + + public static SvGenericPayloadInspection Inspect(ReadOnlyMemory payload) + { + var span = payload.Span; + var completeWordCount = span.Length / WordBytes; + var trailingByteCount = span.Length % WordBytes; + var hasEightByteGroupShape = span.Length >= StructuralGroupBytes && span.Length % StructuralGroupBytes == 0; + var words = new SvGenericPayloadWord[completeWordCount]; + + for (var index = 0; index < completeWordCount; index++) + { + var byteOffset = index * WordBytes; + var wordBytes = span.Slice(byteOffset, WordBytes); + var unsigned = BinaryPrimitives.ReadUInt32BigEndian(wordBytes); + words[index] = new SvGenericPayloadWord + { + Index = index, + ByteOffset = byteOffset, + StructuralRole = ResolveRole(index, hasEightByteGroupShape), + RawBytes = wordBytes.ToArray(), + Hex = Convert.ToHexString(wordBytes), + SignedInt32 = unchecked((int)unsigned), + UnsignedInt32 = unsigned, + Float32 = BitConverter.Int32BitsToSingle(unchecked((int)unsigned)) + }; + } + + var trailingBytes = trailingByteCount == 0 + ? Array.Empty() + : span[^trailingByteCount..].ToArray(); + var diagnostics = BuildDiagnostics(span.Length, completeWordCount, trailingByteCount, hasEightByteGroupShape); + + return new SvGenericPayloadInspection + { + PayloadLength = span.Length, + CompleteWordCount = completeWordCount, + TrailingByteCount = trailingByteCount, + IsFourByteAligned = trailingByteCount == 0, + HasEightByteGroupShape = hasEightByteGroupShape, + Words = words, + TrailingBytes = trailingBytes, + Diagnostics = diagnostics + }; + } + + private static SvGenericPayloadWordRole ResolveRole(int wordIndex, bool hasEightByteGroupShape) + { + if (!hasEightByteGroupShape) + return SvGenericPayloadWordRole.StandaloneWord; + return wordIndex % 2 == 0 + ? SvGenericPayloadWordRole.FirstWordInEightByteGroup + : SvGenericPayloadWordRole.SecondWordInEightByteGroup; + } + + private static IReadOnlyList BuildDiagnostics( + int payloadLength, + int completeWordCount, + int trailingByteCount, + bool hasEightByteGroupShape) + { + var diagnostics = new List(); + if (payloadLength == 0) + { + diagnostics.Add("seqOfData is empty."); + return diagnostics; + } + + diagnostics.Add( + $"Generic inspection exposed {completeWordCount} complete big-endian 32-bit word(s) without assigning channel names or engineering units."); + + if (hasEightByteGroupShape) + { + diagnostics.Add( + "The payload has an 8-byte grouping shape. This is structural evidence only; the second word is not treated as IEC 61850 quality until SCL or an explicit standard layout resolves it."); + } + + if (trailingByteCount > 0) + { + diagnostics.Add( + $"The payload contains {trailingByteCount} trailing byte(s) after the last complete 32-bit word; those bytes are preserved verbatim."); + } + + return diagnostics; + } +} From b927457e7df3bceb7240fd095e9aecb5d3b6b913 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:09:59 +0700 Subject: [PATCH 11/21] Test generic SV payload inspection contract --- .../SvGenericPayloadInspectorTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs new file mode 100644 index 0000000..55eba76 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPayloadInspectorTests.cs @@ -0,0 +1,81 @@ +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericPayloadInspectorTests +{ + [Fact] + public void InspectExposesEveryWordWithoutInventingChannelSemantics() + { + var payload = new byte[64]; + payload[2] = 0x03; + payload[3] = 0xE8; + payload[8] = 0xFF; + payload[9] = 0xFF; + payload[10] = 0xFC; + payload[11] = 0x18; + + var inspection = SvGenericPayloadInspector.Inspect(payload); + + Assert.Equal(64, inspection.PayloadLength); + Assert.Equal(16, inspection.CompleteWordCount); + Assert.True(inspection.IsFourByteAligned); + Assert.True(inspection.HasEightByteGroupShape); + Assert.Equal(1000, inspection.Words[0].SignedInt32); + Assert.Equal(-1000, inspection.Words[2].SignedInt32); + Assert.All(inspection.Words, word => Assert.StartsWith("Word ", word.GenericLabel)); + Assert.DoesNotContain(inspection.Words, word => + word.GenericLabel.Contains("Ia", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Voltage", StringComparison.OrdinalIgnoreCase) || + word.GenericLabel.Contains("Quality", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectUsesBigEndianSignedAndUnsignedViewsOfTheSameBytes() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0xFF, 0xFF, 0xFF, 0xFE }); + + var word = Assert.Single(inspection.Words); + Assert.Equal(-2, word.SignedInt32); + Assert.Equal(4_294_967_294u, word.UnsignedInt32); + Assert.Equal("FFFFFFFE", word.Hex); + Assert.Equal("+0x00", word.OffsetLabel); + Assert.Equal(SvGenericPayloadWordRole.StandaloneWord, word.StructuralRole); + } + + [Fact] + public void InspectMarksEightByteGroupingAsStructuralOnly() + { + var inspection = SvGenericPayloadInspector.Inspect(new byte[16]); + + Assert.Equal(SvGenericPayloadWordRole.FirstWordInEightByteGroup, inspection.Words[0].StructuralRole); + Assert.Equal(SvGenericPayloadWordRole.SecondWordInEightByteGroup, inspection.Words[1].StructuralRole); + Assert.Contains(inspection.Diagnostics, diagnostic => + diagnostic.Contains("structural evidence only", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InspectPreservesTrailingBytesInsteadOfDroppingPayload() + { + var inspection = SvGenericPayloadInspector.Inspect( + new byte[] { 0x00, 0x00, 0x00, 0x01, 0xAA, 0xBB }); + + Assert.False(inspection.IsFourByteAligned); + Assert.Equal(1, inspection.CompleteWordCount); + Assert.Equal(2, inspection.TrailingByteCount); + Assert.Equal(new byte[] { 0xAA, 0xBB }, inspection.TrailingBytes); + Assert.Contains("2 trailing byte(s)", inspection.Summary); + } + + [Fact] + public void InspectHandlesEmptyPayloadExplicitly() + { + var inspection = SvGenericPayloadInspector.Inspect(ReadOnlyMemory.Empty); + + Assert.Empty(inspection.Words); + Assert.Equal("Empty seqOfData payload", inspection.Summary); + Assert.Contains("empty", Assert.Single(inspection.Diagnostics), StringComparison.OrdinalIgnoreCase); + } +} From 771874f51886b3d227b8539ba6e3d178b0a5821e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:10:44 +0700 Subject: [PATCH 12/21] Align scaling tests with generic SCL-driven policy --- .../SampledValuesMeasurementsTests.cs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs index d120bbb..a09634c 100644 --- a/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesMeasurementsTests.cs @@ -6,7 +6,7 @@ namespace ARSVIN.Tests; public sealed class SampledValuesMeasurementsTests { [Fact] - public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() + public void ScaleResolverDoesNotInferCurrentUnitsWithoutSclMapping() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -19,14 +19,15 @@ public void ScaleResolverConvertsFixedProtectionCurrentToAmperes() DeclaredSampleRate = 4_000 }); - Assert.Equal(SvEngineeringScaleSource.Legacy92LeStyleStructuralInference, scale.Source); - Assert.Equal(SvEngineeringScaleConfidence.Inferred, scale.Confidence); - Assert.Equal("A", scale.Unit); - Assert.Equal(1.0, scale.Apply(1_000), 9); + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Equal(SvEngineeringScaleConfidence.Unknown, scale.Confidence); + Assert.Equal("count", scale.Unit); + Assert.Equal(1_000, scale.Apply(1_000)); + Assert.Contains("No SCL dataset mapping", scale.Reason, StringComparison.OrdinalIgnoreCase); } [Fact] - public void ScaleResolverConvertsFixedProtectionVoltageToVolts() + public void ScaleResolverConvertsSclMappedFixedProtectionVoltageToVolts() { var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence { @@ -35,7 +36,9 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() IsSclBound = true, IsFixedFourCurrentFourVoltageLayout = true, AnalogChannelCount = 8, - PayloadBytesPerAsdu = 64 + PayloadBytesPerAsdu = 64, + DeclaredSampleMode = 1, + DeclaredSampleRate = 4_000 }); Assert.Equal(SvEngineeringScaleSource.SclBackedLegacy92LeStyle, scale.Source); @@ -44,6 +47,23 @@ public void ScaleResolverConvertsFixedProtectionVoltageToVolts() Assert.Equal(100.0, scale.Apply(10_000), 9); } + [Fact] + public void ScaleResolverWithholdsFixedLayoutScalingWhenRateEvidenceIsMissing() + { + var scale = SvEngineeringScaleResolver.Resolve(new SvEngineeringScaleEvidence + { + Channel = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + IsSclBound = true, + IsFixedFourCurrentFourVoltageLayout = true, + AnalogChannelCount = 8, + PayloadBytesPerAsdu = 64 + }); + + Assert.Equal(SvEngineeringScaleSource.RawOnly, scale.Source); + Assert.Contains("rate evidence", scale.Reason, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { @@ -51,6 +71,7 @@ public void ScaleResolverKeepsUnknownLayoutAsRawCounts() { Channel = "Ia", Kind = "Current", + IsSclBound = true, AnalogChannelCount = 12, PayloadBytesPerAsdu = 96, ObservedSamplesPerSecond = 4_000 From 05ccc0b297e59da3a62c60507a2c3ed43426f883 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:11:21 +0700 Subject: [PATCH 13/21] Add generic SV ASDU inspection model --- .../Analysis/SvGenericAsduInspector.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs new file mode 100644 index 0000000..da5ef74 --- /dev/null +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs @@ -0,0 +1,58 @@ +namespace AR.Iec61850.SampledValues.Analysis; + +/// +/// Generic, vendor-neutral view of one Sampled Values ASDU. +/// It separates fields observed on the wire from semantic dataset interpretation. +/// +public sealed record SvGenericAsduInspection +{ + public string SvId { get; init; } = string.Empty; + public string DataSetReference { get; init; } = string.Empty; + public ushort SampleCount { get; init; } + public uint ConfigurationRevision { get; init; } + public bool HasReferenceTime { get; init; } + public byte SampleSynchronization { get; init; } + public ushort? SampleRate { get; init; } + public ushort? SampleMode { get; init; } + public SvGenericPayloadInspection Payload { get; init; } = new(); + + public string MappingState => string.IsNullOrWhiteSpace(DataSetReference) + ? "Dataset reference not present · semantic mapping unresolved" + : "Dataset reference observed · import or bind SCL to resolve ordered semantics"; + + public string OptionalFieldSummary + { + get + { + var fields = new List(); + if (HasReferenceTime) + fields.Add("refrTm"); + if (SampleRate.HasValue) + fields.Add("smpRate"); + if (SampleMode.HasValue) + fields.Add("smpMod"); + return fields.Count == 0 ? "No optional ASDU fields observed" : string.Join(", ", fields); + } + } +} + +public static class SvGenericAsduInspector +{ + public static SvGenericAsduInspection Inspect(SampledValueAsdu asdu) + { + ArgumentNullException.ThrowIfNull(asdu); + + return new SvGenericAsduInspection + { + SvId = asdu.SvId, + DataSetReference = asdu.DataSetReference, + SampleCount = asdu.SampleCount, + ConfigurationRevision = asdu.ConfigurationRevision, + HasReferenceTime = asdu.ReferenceTime is not null, + SampleSynchronization = asdu.SampleSynchronization, + SampleRate = asdu.SampleRate, + SampleMode = asdu.SampleMode, + Payload = SvGenericPayloadInspector.Inspect(asdu.SamplePayload) + }; + } +} From 3f9420c525910f6a3d4271d03bcd7c4ee3e57294 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:11:36 +0700 Subject: [PATCH 14/21] Test generic SV ASDU inspection --- .../Analysis/SvGenericAsduInspectorTests.cs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs new file mode 100644 index 0000000..988fa45 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericAsduInspectorTests.cs @@ -0,0 +1,51 @@ +using AR.Iec61850.SampledValues; +using AR.Iec61850.SampledValues.Analysis; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericAsduInspectorTests +{ + [Fact] + public void InspectKeepsWireFieldsSeparateFromDatasetSemantics() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + DataSetReference = "LD0/LLN0$Dataset1", + SampleCount = 123, + ConfigurationRevision = 7, + SampleSynchronization = 2, + SampleRate = 4_800, + SampleMode = 1, + SamplePayload = new byte[12] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Equal("MU01", inspection.SvId); + Assert.Equal((ushort)123, inspection.SampleCount); + Assert.Equal((uint)7, inspection.ConfigurationRevision); + Assert.Equal((ushort)4_800, inspection.SampleRate); + Assert.Equal((ushort)1, inspection.SampleMode); + Assert.Equal(3, inspection.Payload.CompleteWordCount); + Assert.Contains("bind SCL", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Contains("smpRate", inspection.OptionalFieldSummary); + Assert.Contains("smpMod", inspection.OptionalFieldSummary); + } + + [Fact] + public void InspectReportsUnresolvedMappingWhenDatasetReferenceIsAbsent() + { + var asdu = new SampledValueAsdu + { + SvId = "MU01", + SamplePayload = new byte[8] + }; + + var inspection = SvGenericAsduInspector.Inspect(asdu); + + Assert.Contains("unresolved", inspection.MappingState, StringComparison.OrdinalIgnoreCase); + Assert.Equal("No optional ASDU fields observed", inspection.OptionalFieldSummary); + } +} From 8609a38a60ef581a6bb9281522cf454e1a9dc8a2 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:12:32 +0700 Subject: [PATCH 15/21] Document generic ArSubsv audit and roadmap --- docs/arsubsv-generic-explorer-roadmap.md | 204 +++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/arsubsv-generic-explorer-roadmap.md diff --git a/docs/arsubsv-generic-explorer-roadmap.md b/docs/arsubsv-generic-explorer-roadmap.md new file mode 100644 index 0000000..a356217 --- /dev/null +++ b/docs/arsubsv-generic-explorer-roadmap.md @@ -0,0 +1,204 @@ +# ArSubsv generic Sampled Values explorer roadmap + +## Product decision + +ArSubsv is a generic IEC 61850 Sampled Values subscriber, inspector, measurement viewer, and evidence tool. + +The receive path must not contain device-family branches such as ABB, Siemens, SEL, GE, or any other manufacturer identity. Product identity may appear in imported evidence or reports, but it must never select a parser, dataset order, engineering scale, quality layout, or health result. + +The intended receive pipeline is: + +```text +Ethernet / VLAN / APPID + ↓ +generic IEC 61850-9-2 APDU and ASDU parser + ↓ +generic seqOfData structural inspection + ↓ +optional SCL-driven ordered dataset mapping + ↓ +explicit standard-layout or engineering-context evidence + ↓ +waveform, RMS, phasor, quality, and report +``` + +## Reference behavior + +A proven SV engineering workflow discovers one or more streams, shows waveforms, RMS values, phase angles, phasors, individual samples, recording/replay, and reports. Configurable IEC 61869-9 datasets are interpreted and checked through imported SCL rather than manufacturer heuristics. + +ArSubsv should reproduce that workflow while keeping every interpretation explainable. + +## Audit findings + +### Correct foundations already present + +- One generic Ethernet/APDU/ASDU parser is shared across live capture and PCAP replay. +- SCL can provide an ordered payload layout and configuration comparison. +- Raw protocol values, scaling provenance, timebase provenance, quality diagnostics, and evidence reports already exist. +- Selected-stream visualization is stable and bounded. +- CT/VT primary/secondary display context is explicit and does not alter raw bytes. + +### Incorrect or unsafe behavior to remove + +1. **Unbound payload auto-layout** + + The current Subscriber recognizes selected payload pair counts and assigns names such as Ia, Ib, Ic, In, Va, Vb, Vc, and Vn. A 64-byte payload can therefore be interpreted as a fixed 4I+4V layout without SCL. This is structural guessing and can mislabel configurable IEC 61869-9 datasets. + +2. **Synthetic IEC references** + + Generic traffic can receive generated references such as `TCTR1/AmpSv.instMag.i`. Those references look authoritative even though they were not read from SCL or the wire. + +3. **Unbound engineering scaling** + + Packet length and observed sampling rate were previously sufficient to activate provisional A/V scaling. This is now hardened: generic unbound traffic remains raw counts. + +4. **Unknown mapping treated as stream warning** + + A valid stream without SCL is currently classified as WARN because channel names or scaling are unresolved. Missing semantics are not a protocol failure. Protocol/stream integrity and interpretation completeness must be separate states. + +5. **Waveform model fixed to eight named channels** + + The current waveform container is optimized for Ia/Ib/Ic/In and Va/Vb/Vc/Vn. Configurable datasets require a generic selected-element trace model before semantic aliases are added. + +6. **Profile-oriented compact state** + + The UI emphasizes profile and confidence. The primary receive workflow should instead emphasize wire validity, mapping source, semantic completeness, and measurement provenance. + +7. **Global parse health can remain sticky** + + Lifetime parse-error count can keep the global state BAD after the current traffic has recovered. Lifetime counters should remain evidence, while current health should use a rolling window. + +## Implemented in P3A + +### Generic seqOfData inspection + +`SvGenericPayloadInspector` exposes every complete big-endian 32-bit word through: + +- byte offset, +- raw hexadecimal bytes, +- signed INT32 representation, +- unsigned UINT32 representation, +- IEEE-754 FLOAT32 representation, +- structural position in an eight-byte group when applicable. + +An eight-byte grouping shape is only structural evidence. The second word is not called quality until SCL or an explicit reviewed standard layout resolves it. + +Trailing bytes are preserved and reported instead of silently discarded. + +### Generic ASDU inspection + +`SvGenericAsduInspector` exposes: + +- `svID`, +- dataset reference, +- `smpCnt`, +- `confRev`, +- `smpSynch`, +- presence of `refrTm`, +- optional `smpRate` and `smpMod`, +- generic payload inspection. + +Dataset reference presence does not mean dataset semantics are already known; SCL binding is still required. + +### Scaling hardening + +Engineering A/V scaling now requires all of the following: + +1. SCL-derived current or voltage semantics, +2. fixed 4-current/4-voltage value-quality structure, +3. expected payload size, +4. declared or observed protection-rate evidence. + +Packet shape, rate, product name, MAC, APPID, `svID`, amplitude, and manufacturer identity cannot activate scaling on their own. + +## Next implementation tranches + +### P3B — integrate generic explorer into live Subscriber + +Replace the unbound auto-layout path with `SvGenericPayloadInspector`. + +Without SCL, the Decoded table must show: + +| Field | Meaning | +|---|---| +| Offset | byte position inside seqOfData | +| Word | generic word index | +| INT32 | signed big-endian representation | +| UINT32 | unsigned representation | +| FLOAT32 | alternate representation, clearly marked | +| Raw | exact bytes | +| Semantics | unresolved | + +No Ia/Va labels, no A/V units, no quality health decisions, and no phasor calculation are permitted in raw generic mode. + +### P3C — SCL-driven semantic mapping + +Resolve the ordered path: + +```text +SampledValueControl + → datSet + → DataSet FCDA order + → DO/DA/BType/CDC + → payload element decoder +``` + +Each displayed field must carry a mapping source: + +- wire observed, +- SCL derived, +- explicit standard-layout selection, +- manual engineering context, +- device-validated evidence. + +Unknown, absent, and conflicting states must remain distinct. + +### P3D — generic selected-element waveform + +Introduce a generic numeric trace model independent of Ia/Ib/Ic/Va/Vb/Vc. + +- Any mapped numeric element can be selected for waveform display. +- Raw mode may plot an explicitly selected word as counts. +- RMS is available when a complete time window exists. +- Phase angle and phasor are available only when nominal frequency/timebase and signal semantics are resolved. +- Standard phase aliases are conveniences derived from SCL, not parser assumptions. + +### P3E — explainable health layers + +Expose four independent states: + +```text +PROTOCOL GOOD / BAD +STREAM GOOD / WARN / BAD +CONFIGURATION MATCH / WARN / BAD / NOT LOADED +MEASUREMENT RESOLVED / PARTIAL / RAW +``` + +The global headline should be driven by current protocol and stream integrity. Missing SCL or unresolved engineering scale must not turn a valid stream BAD. + +### P3F — evidence and workflow parity + +- record/replay and COMTRADE export, +- detailed individual-sample view, +- zero-crossing and timing statistics, +- packet-interval histogram, +- optional-field verification, +- SCL orphan discovery, +- printable and JSON evidence reports, +- selected-stream stability under live refresh, +- capture/application drop attribution without claiming kernel drops that were not observed. + +## Real-device evidence policy + +PCAP and SCL from an ABB SMU615, Siemens merging unit, SEL device, or any other product are regression fixtures for the generic engine. They are not permission to add product-specific decoding branches. + +A real-device fixture should prove: + +- generic parser compatibility, +- SCL mapping correctness, +- sample-counter and timing behavior, +- quality interpretation, +- engineering-value agreement with known injection, +- report reproducibility. + +Any device-specific exception must be documented as an implementation defect or explicit compatibility observation, isolated from the generic standards path, and never silently selected by manufacturer identity. From 4a4c008a9fedd95faf90b22537e0f04679de6368 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:14:31 +0700 Subject: [PATCH 16/21] Add generic Subscriber presentation layer --- .../SvStreamViewModel.GenericExplorer.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs diff --git a/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs new file mode 100644 index 0000000..689f1ad --- /dev/null +++ b/src/ARSVIN.Subscriber/ViewModels/SvStreamViewModel.GenericExplorer.cs @@ -0,0 +1,146 @@ +using System.Buffers.Binary; +using System.Collections.Specialized; +using System.ComponentModel; +using AR.Iec61850.SampledValues.Measurements; +using ARSVIN.Subscriber.Models; + +namespace ARSVIN.Subscriber.ViewModels; + +public sealed partial class SvStreamViewModel +{ + private readonly BulkObservableCollection _genericValues = new(); + private readonly BulkObservableCollection _genericWaveformPoints = new(); + private readonly BulkObservableCollection _genericPhasors = new(); + private string _genericMappingState = "Raw seqOfData"; + private string _genericSemanticState = "Unresolved · no assumptions"; + private string _genericWaveformState = "Waiting for stream data"; + + public SvStreamViewModel() + { + _values.CollectionChanged += SourceCollectionChanged; + _waveformPoints.CollectionChanged += SourceCollectionChanged; + _phasors.CollectionChanged += SourceCollectionChanged; + PropertyChanged += StreamPropertyChanged; + RefreshGenericPresentation(); + } + + public IReadOnlyList GenericValues => _genericValues; + public IReadOnlyList GenericWaveformPoints => _genericWaveformPoints; + public IReadOnlyList GenericPhasors => _genericPhasors; + + public string GenericMappingState + { + get => _genericMappingState; + private set => SetProperty(ref _genericMappingState, value); + } + + public string GenericSemanticState + { + get => _genericSemanticState; + private set => SetProperty(ref _genericSemanticState, value); + } + + public string GenericWaveformState + { + get => _genericWaveformState; + private set => SetProperty(ref _genericWaveformState, value); + } + + private void SourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => RefreshGenericPresentation(); + + private void StreamPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(Bound) or nameof(WaveformState) or nameof(Scaling)) + RefreshGenericPresentation(); + } + + private void RefreshGenericPresentation() + { + if (HasSclSemanticMapping()) + { + GenericMappingState = "SCL dataset mapping"; + GenericSemanticState = "Resolved from ordered SCL elements"; + GenericWaveformState = WaveformState; + _genericValues.ReplaceAll(_values); + _genericWaveformPoints.ReplaceAll(_waveformPoints); + _genericPhasors.ReplaceAll(_phasors); + return; + } + + GenericMappingState = "Raw seqOfData"; + GenericSemanticState = "Unresolved · words shown without channel, unit, or quality claims"; + GenericWaveformState = _values.Count == 0 + ? "Waiting for seqOfData" + : "Raw words available · import SCL before semantic waveform and phasor analysis"; + _genericValues.ReplaceAll(BuildGenericRows(_values)); + _genericWaveformPoints.ReplaceAll(Array.Empty()); + _genericPhasors.ReplaceAll(Array.Empty()); + } + + private bool HasSclSemanticMapping() + => Bound.StartsWith("SCL:", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList BuildGenericRows(IReadOnlyList source) + { + if (source.Count == 0) + return Array.Empty(); + + var rows = new List(source.Count); + for (var index = 0; index < source.Count; index++) + { + var original = source[index]; + var byteOffset = index * 4; + if (!TryReadWord(original.Raw, out var signed, out var unsigned)) + { + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Bytes {index + 1} (+0x{byteOffset:X2})", + Kind = "Raw bytes", + Value = original.Raw, + Raw = original.Raw, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "No SCL mapping is bound; bytes are preserved without semantic interpretation." + }); + continue; + } + + rows.Add(new DecodedValueRow + { + Index = index + 1, + Signal = $"Word {index + 1} (+0x{byteOffset:X2})", + Kind = index % 2 == 0 ? "INT32 / UINT32 · group word 1" : "INT32 / UINT32 · group word 2", + Value = $"{signed} / {unsigned}", + Raw = original.Raw, + NumericValue = signed, + ScalingSource = SvEngineeringScaleSource.RawOnly, + ScalingConfidence = SvEngineeringScaleConfidence.Unknown, + ScalingReason = "Generic 32-bit representation only. Channel, quality, unit, and scaling semantics are unresolved until SCL or explicit reviewed mapping is available." + }); + } + + return rows; + } + + private static bool TryReadWord(string rawHex, out int signed, out uint unsigned) + { + signed = 0; + unsigned = 0; + if (string.IsNullOrWhiteSpace(rawHex) || rawHex.Length != 8) + return false; + + try + { + var bytes = Convert.FromHexString(rawHex); + unsigned = BinaryPrimitives.ReadUInt32BigEndian(bytes); + signed = unchecked((int)unsigned); + return true; + } + catch (FormatException) + { + return false; + } + } +} From 7171d4736a6f7faf45f5d2f2c51eb63c9f1ccdb0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:15:35 +0700 Subject: [PATCH 17/21] Present unbound SV payloads generically in Subscriber UI --- src/ARSVIN.Subscriber/MainWindow.xaml.cs | 78 +++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/src/ARSVIN.Subscriber/MainWindow.xaml.cs b/src/ARSVIN.Subscriber/MainWindow.xaml.cs index 0a42d46..0a3398d 100644 --- a/src/ARSVIN.Subscriber/MainWindow.xaml.cs +++ b/src/ARSVIN.Subscriber/MainWindow.xaml.cs @@ -25,7 +25,11 @@ public MainWindow() DataContext = _viewModel; _viewModel.Streams.CollectionChanged += Streams_CollectionChanged; AttachLivePlotControls(); - Loaded += (_, _) => AttachMeasurementContextToolbar(); + Loaded += (_, _) => + { + AttachMeasurementContextToolbar(); + AttachGenericExplorerUi(); + }; } private void AttachLivePlotControls() @@ -34,17 +38,85 @@ private void AttachLivePlotControls() BindingOperations.SetBinding( scope, OscilloscopePlot.PointsProperty, - new Binding("SelectedStream.WaveformPoints")); + new Binding("SelectedStream.GenericWaveformPoints")); ScopeHost.Child = scope; var phasor = new PhasorPlot(); BindingOperations.SetBinding( phasor, PhasorPlot.VectorsProperty, - new Binding("SelectedStream.Phasors")); + new Binding("SelectedStream.GenericPhasors")); PhasorHost.Child = phasor; } + private void AttachGenericExplorerUi() + { + foreach (var text in FindVisualChildren(this)) + { + if (string.Equals(text.Text, "PROFILE", StringComparison.Ordinal)) + text.Text = "MAPPING"; + else if (string.Equals(text.Text, "CONFIDENCE", StringComparison.Ordinal)) + text.Text = "SEMANTICS"; + + var binding = BindingOperations.GetBinding(text, TextBlock.TextProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Profile", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericMappingState")); + } + else if (string.Equals(path, "SelectedStream.Confidence", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericSemanticState")); + } + else if (string.Equals(path, "SelectedStream.WaveformState", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + text, + TextBlock.TextProperty, + new Binding("SelectedStream.GenericWaveformState")); + } + } + + foreach (var grid in FindVisualChildren(this)) + { + var binding = BindingOperations.GetBinding(grid, ItemsControl.ItemsSourceProperty); + var path = binding?.Path?.Path; + if (string.Equals(path, "SelectedStream.Phasors", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericPhasors")); + } + else if (string.Equals(path, "SelectedValues", StringComparison.Ordinal)) + { + BindingOperations.SetBinding( + grid, + ItemsControl.ItemsSourceProperty, + new Binding("SelectedStream.GenericValues")); + UpdateGenericDecodedColumns(grid); + } + } + } + + private static void UpdateGenericDecodedColumns(DataGrid grid) + { + if (grid.Columns.Count < 6) + return; + + grid.Columns[1].Header = "Word / SCL signal"; + grid.Columns[2].Header = "Representation"; + grid.Columns[3].Header = "Value"; + grid.Columns[4].Header = "Semantics"; + grid.Columns[5].Header = "Raw bytes"; + } + private void AttachMeasurementContextToolbar() { var toolbar = FindVisualChildren(this) From b03c5e891fd1b6ed3a0b57dcffe0205b06be8826 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:16:36 +0700 Subject: [PATCH 18/21] Keep generic explorer roadmap vendor-neutral --- docs/arsubsv-generic-explorer-roadmap.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/arsubsv-generic-explorer-roadmap.md b/docs/arsubsv-generic-explorer-roadmap.md index a356217..50e947b 100644 --- a/docs/arsubsv-generic-explorer-roadmap.md +++ b/docs/arsubsv-generic-explorer-roadmap.md @@ -4,7 +4,7 @@ ArSubsv is a generic IEC 61850 Sampled Values subscriber, inspector, measurement viewer, and evidence tool. -The receive path must not contain device-family branches such as ABB, Siemens, SEL, GE, or any other manufacturer identity. Product identity may appear in imported evidence or reports, but it must never select a parser, dataset order, engineering scale, quality layout, or health result. +The receive path must not contain manufacturer or device-family branches. Product identity may appear in imported evidence or reports, but it must never select a parser, dataset order, engineering scale, quality layout, or health result. The intended receive pipeline is: @@ -111,13 +111,25 @@ Engineering A/V scaling now requires all of the following: Packet shape, rate, product name, MAC, APPID, `svID`, amplitude, and manufacturer identity cannot activate scaling on their own. +### Generic Subscriber presentation + +When an SCL mapping is not bound, the Subscriber presentation layer now: + +- replaces synthetic channel labels with generic word labels and byte offsets, +- shows signed and unsigned representations of each 32-bit word, +- labels semantics as unresolved, +- suppresses semantic waveform, RMS, and phasor displays, +- renames the compact profile indicators to mapping and semantics. + +When SCL is bound, existing ordered SCL decoding and measurement views remain available. + ## Next implementation tranches -### P3B — integrate generic explorer into live Subscriber +### P3B — replace the runtime auto-layout path -Replace the unbound auto-layout path with `SvGenericPayloadInspector`. +Replace the unbound runtime auto-layout decoder with `SvGenericPayloadInspector`, so synthetic channel references are not created internally at all. -Without SCL, the Decoded table must show: +Without SCL, the Decoded table contract is: | Field | Meaning | |---|---| @@ -190,7 +202,7 @@ The global headline should be driven by current protocol and stream integrity. M ## Real-device evidence policy -PCAP and SCL from an ABB SMU615, Siemens merging unit, SEL device, or any other product are regression fixtures for the generic engine. They are not permission to add product-specific decoding branches. +PCAP and SCL from any conforming or installed-base device are regression fixtures for the generic engine. They are not permission to add manufacturer-specific decoding branches. A real-device fixture should prove: From 841ccec20279280f0e9f4d05519b6e45ea8f8a3f Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:17:17 +0700 Subject: [PATCH 19/21] Test generic Subscriber presentation contract --- .../SvGenericPresentationContractTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs new file mode 100644 index 0000000..217de80 --- /dev/null +++ b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using ARSVIN.Subscriber.Models; +using ARSVIN.Subscriber.ViewModels; +using Xunit; + +namespace ARSVIN.Tests.SampledValues.Analysis; + +public sealed class SvGenericPresentationContractTests +{ + [Fact] + public void UnboundPresentationUsesGenericWordsAndSuppressesSemanticPlots() + { + var row = new SvStreamViewModel + { + Bound = "Auto fixed 4I+4V value-quality layout", + WaveformState = "2 cycles locked" + }; + + var valuesField = typeof(SvStreamViewModel).GetField("_values", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(valuesField); + var values = Assert.IsAssignableFrom>(valuesField!.GetValue(row)); + values.Add(new DecodedValueRow + { + Index = 1, + Signal = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + Value = "1000", + Raw = "000003E8", + NumericValue = 1000 + }); + values.Add(new DecodedValueRow + { + Index = 2, + Signal = "TCTR1/AmpSv.instMag.i.q", + Kind = "Quality", + Value = "00000000", + Raw = "00000000" + }); + + Assert.Equal("Raw seqOfData", row.GenericMappingState); + Assert.Contains("Unresolved", row.GenericSemanticState, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, row.GenericValues.Count); + Assert.Equal("Word 1 (+0x00)", row.GenericValues[0].Signal); + Assert.Equal("1000 / 1000", row.GenericValues[0].Value); + Assert.DoesNotContain(row.GenericValues, value => value.IsQuality); + Assert.Empty(row.GenericWaveformPoints); + Assert.Empty(row.GenericPhasors); + } + + [Fact] + public void SclBoundPresentationKeepsResolvedRows() + { + var row = new SvStreamViewModel + { + Bound = "SCL: IED1/LLN0.SMV1" + }; + + var valuesField = typeof(SvStreamViewModel).GetField("_values", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(valuesField); + var values = Assert.IsAssignableFrom>(valuesField!.GetValue(row)); + values.Add(new DecodedValueRow + { + Index = 1, + Signal = "TCTR1/AmpSv.instMag.i", + Kind = "Current", + Value = "1000", + Raw = "000003E8", + NumericValue = 1000 + }); + + Assert.Equal("SCL dataset mapping", row.GenericMappingState); + Assert.Equal("TCTR1/AmpSv.instMag.i", Assert.Single(row.GenericValues).Signal); + } +} From 7082bb2f538ef8eee0a9d0bef5a81fe7bec048e0 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:18:30 +0700 Subject: [PATCH 20/21] Keep generic payload contract terminology neutral --- .../SampledValues/Analysis/SvGenericPayloadInspector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs index 7ff3642..6b201c7 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs @@ -69,7 +69,7 @@ public string Summary /// /// Generic seqOfData inspector used when no trusted dataset layout is available. -/// It never labels words as Ia/Ib/Ic, voltage, current, quality, primary, secondary, A, or V. +/// It never labels words as phase channels, voltage, current, quality, primary, secondary, A, or V. /// public static class SvGenericPayloadInspector { From 12dd0ab41d9bfe77d74c7eca48c7aaeb806069da Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 05:20:46 +0700 Subject: [PATCH 21/21] Remove Subscriber-only test from engine test project --- .../SvGenericPresentationContractTests.cs | 74 ------------------- 1 file changed, 74 deletions(-) delete mode 100644 tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs diff --git a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs b/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs deleted file mode 100644 index 217de80..0000000 --- a/tests/ARSVIN.Tests/SampledValues/Analysis/SvGenericPresentationContractTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Reflection; -using ARSVIN.Subscriber.Models; -using ARSVIN.Subscriber.ViewModels; -using Xunit; - -namespace ARSVIN.Tests.SampledValues.Analysis; - -public sealed class SvGenericPresentationContractTests -{ - [Fact] - public void UnboundPresentationUsesGenericWordsAndSuppressesSemanticPlots() - { - var row = new SvStreamViewModel - { - Bound = "Auto fixed 4I+4V value-quality layout", - WaveformState = "2 cycles locked" - }; - - var valuesField = typeof(SvStreamViewModel).GetField("_values", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(valuesField); - var values = Assert.IsAssignableFrom>(valuesField!.GetValue(row)); - values.Add(new DecodedValueRow - { - Index = 1, - Signal = "TCTR1/AmpSv.instMag.i", - Kind = "Current", - Value = "1000", - Raw = "000003E8", - NumericValue = 1000 - }); - values.Add(new DecodedValueRow - { - Index = 2, - Signal = "TCTR1/AmpSv.instMag.i.q", - Kind = "Quality", - Value = "00000000", - Raw = "00000000" - }); - - Assert.Equal("Raw seqOfData", row.GenericMappingState); - Assert.Contains("Unresolved", row.GenericSemanticState, StringComparison.OrdinalIgnoreCase); - Assert.Equal(2, row.GenericValues.Count); - Assert.Equal("Word 1 (+0x00)", row.GenericValues[0].Signal); - Assert.Equal("1000 / 1000", row.GenericValues[0].Value); - Assert.DoesNotContain(row.GenericValues, value => value.IsQuality); - Assert.Empty(row.GenericWaveformPoints); - Assert.Empty(row.GenericPhasors); - } - - [Fact] - public void SclBoundPresentationKeepsResolvedRows() - { - var row = new SvStreamViewModel - { - Bound = "SCL: IED1/LLN0.SMV1" - }; - - var valuesField = typeof(SvStreamViewModel).GetField("_values", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(valuesField); - var values = Assert.IsAssignableFrom>(valuesField!.GetValue(row)); - values.Add(new DecodedValueRow - { - Index = 1, - Signal = "TCTR1/AmpSv.instMag.i", - Kind = "Current", - Value = "1000", - Raw = "000003E8", - NumericValue = 1000 - }); - - Assert.Equal("SCL dataset mapping", row.GenericMappingState); - Assert.Equal("TCTR1/AmpSv.instMag.i", Assert.Single(row.GenericValues).Signal); - } -}