From 3ccf92e27b52241d87a3634649240f67f1cff332 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 22 Jul 2026 16:19:56 +0700 Subject: [PATCH 01/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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); - } -} From 451e8b6ead871d292142674b44f18a7a6d457816 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:24:43 +0700 Subject: [PATCH 22/61] Define ARIEC61850 sibling project ownership --- Directory.Build.props | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Directory.Build.props b/Directory.Build.props index d584239..9f98f9a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,5 +17,13 @@ Ari Sulistiono Ari Sulistiono Copyright © 2026 Ari Sulistiono + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\ARIEC61850')) + $(ARIEC61850_ROOT)\src\AR.Iec61850\AR.Iec61850.csproj + $(ARIEC61850_ROOT)\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj From 8a18c46a3ba8d5b497688569d8cb742c40f18be7 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:24:56 +0700 Subject: [PATCH 23/61] Fail fast when the ARIEC61850 sibling is missing --- Directory.Build.targets | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 Directory.Build.targets diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..97a028e --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,10 @@ + + + + + + From 0c559f444ea4be0663119e49c45e9f84393d3738 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:25:14 +0700 Subject: [PATCH 24/61] Reference the sibling ARIEC61850 engine from Publisher --- src/ARSVIN/ARSVIN.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN/ARSVIN.csproj b/src/ARSVIN/ARSVIN.csproj index a5f0d17..3d16457 100644 --- a/src/ARSVIN/ARSVIN.csproj +++ b/src/ARSVIN/ARSVIN.csproj @@ -17,7 +17,8 @@ - + + From 7de338e87aea9bb0d182418cc517af7e07527a58 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:25:31 +0700 Subject: [PATCH 25/61] Reference the sibling ARIEC61850 engine from Subscriber --- src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj b/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj index 67ad17e..1ebc917 100644 --- a/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj +++ b/src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj @@ -17,7 +17,8 @@ - + + From da186cf3c900666d8a733d7e1f55c967be392865 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:25:46 +0700 Subject: [PATCH 26/61] Run ARSVIN regression tests against sibling ARIEC61850 --- tests/ARSVIN.Tests/ARSVIN.Tests.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj index e39f9f9..9c8b8e1 100644 --- a/tests/ARSVIN.Tests/ARSVIN.Tests.csproj +++ b/tests/ARSVIN.Tests/ARSVIN.Tests.csproj @@ -22,7 +22,8 @@ - + + From 55c96eb13ff6196b674d24ecba582ed3d760b2f3 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:26:22 +0700 Subject: [PATCH 27/61] Remove embedded engine from the active solution --- ARSVIN.sln | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ARSVIN.sln b/ARSVIN.sln index 8142fe5..b401f71 100644 --- a/ARSVIN.sln +++ b/ARSVIN.sln @@ -2,7 +2,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN.Engine", "src\ARSVIN.Engine\ARSVIN.Engine.csproj", "{B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AR.Iec61850", "..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj", "{05261652-315F-4B52-89D3-7F9E26DF018D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AR.Iec61850.Transports.Npcap", "..\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj", "{2294F7F2-6229-4578-B300-0ECBD8EC11CE}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN", "src\ARSVIN\ARSVIN.csproj", "{C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}" EndProject @@ -16,10 +18,14 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B6A2A1F7-2B1A-47B5-9C73-1A94C60D0B8E}.Release|Any CPU.Build.0 = Release|Any CPU + {05261652-315F-4B52-89D3-7F9E26DF018D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05261652-315F-4B52-89D3-7F9E26DF018D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05261652-315F-4B52-89D3-7F9E26DF018D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05261652-315F-4B52-89D3-7F9E26DF018D}.Release|Any CPU.Build.0 = Release|Any CPU + {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Release|Any CPU.Build.0 = Release|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Release|Any CPU.ActiveCfg = Release|Any CPU From 9d24735c06f4593605a1addb0fa3660626b759b1 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:27:12 +0700 Subject: [PATCH 28/61] Keep the solution application-only while project references resolve the sibling engine --- ARSVIN.sln | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ARSVIN.sln b/ARSVIN.sln index b401f71..e3bc36f 100644 --- a/ARSVIN.sln +++ b/ARSVIN.sln @@ -2,10 +2,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AR.Iec61850", "..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj", "{05261652-315F-4B52-89D3-7F9E26DF018D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AR.Iec61850.Transports.Npcap", "..\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj", "{2294F7F2-6229-4578-B300-0ECBD8EC11CE}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN", "src\ARSVIN\ARSVIN.csproj", "{C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ARSVIN.Subscriber", "src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj", "{A9F2E511-7D62-4C74-99CF-0E59B8D51377}" @@ -18,14 +14,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {05261652-315F-4B52-89D3-7F9E26DF018D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {05261652-315F-4B52-89D3-7F9E26DF018D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {05261652-315F-4B52-89D3-7F9E26DF018D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {05261652-315F-4B52-89D3-7F9E26DF018D}.Release|Any CPU.Build.0 = Release|Any CPU - {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2294F7F2-6229-4578-B300-0ECBD8EC11CE}.Release|Any CPU.Build.0 = Release|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2A1F2F6-4C1A-4D6A-A3F1-1E4FD13C4D01}.Release|Any CPU.ActiveCfg = Release|Any CPU From 265ee0fb381cca1673a0ed0e54ac316f0ff734b2 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:28:07 +0700 Subject: [PATCH 29/61] Measure coverage against sibling ARIEC61850 core --- scripts/test-with-coverage.ps1 | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index d95d7d4..c741792 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -33,13 +33,13 @@ if ($NoRestore) { $arguments += '--no-restore' } -# Instrument the complete production engine. CI protects both the truthful -# whole-engine baseline and the higher protocol-core regression baseline. +# ARSVIN tests now execute against the sibling ARIEC61850 source of truth. +# Instrument the reusable core assembly rather than the retired embedded ARSVIN.Engine copy. $arguments += @( '/p:RestoreLockedMode=true', '/p:TreatWarningsAsErrors=true', '/p:CollectCoverage=true', - '/p:Include=[ARSVIN.Engine]*', + '/p:Include=[AR.Iec61850]*', '/p:ExcludeAssembliesWithoutSources=None', '/p:CoverletOutputFormat=cobertura', "/p:CoverletOutput=$coveragePrefix", @@ -130,9 +130,9 @@ if ($coreLinesValid -le 0) { $coreLineCoverage = [Math]::Round(($coreLinesCovered / $coreLinesValid) * 100, 2) -Write-Host "Whole engine lines: $overallLinesValid" -Write-Host "Whole engine line coverage: $overallLineCoverage%" -Write-Host "Whole engine minimum required: $MinimumWholeEngineLineCoverage%" +Write-Host "ARIEC61850 core lines: $overallLinesValid" +Write-Host "ARIEC61850 core line coverage: $overallLineCoverage%" +Write-Host "Whole-core minimum required: $MinimumWholeEngineLineCoverage%" Write-Host "Protocol core files: $($coreFiles.Count)" Write-Host "Protocol core lines: $coreLinesValid" Write-Host "Protocol core covered lines: $coreLinesCovered" @@ -142,13 +142,13 @@ Write-Host "Coverage report: $coverageFile" if ($env:GITHUB_STEP_SUMMARY) { @" -## Test coverage +## Test coverage against sibling ARIEC61850 | Metric | Result | |---|---:| -| Whole `ARSVIN.Engine` instrumented lines | **$overallLinesValid** | -| Whole engine line coverage | **$overallLineCoverage%** | -| Whole-engine regression floor | **$MinimumWholeEngineLineCoverage%** | +| Whole `AR.Iec61850` instrumented lines | **$overallLinesValid** | +| Whole core line coverage | **$overallLineCoverage%** | +| Whole-core regression floor | **$MinimumWholeEngineLineCoverage%** | | Tested protocol-core files | **$($coreFiles.Count)** | | Protocol-core instrumented lines | **$coreLinesValid** | | Protocol-core covered lines | **$coreLinesCovered** | @@ -160,7 +160,7 @@ if ($env:GITHUB_STEP_SUMMARY) { $coverageFailures = [System.Collections.Generic.List[string]]::new() if ($overallLineCoverage -lt $MinimumWholeEngineLineCoverage) { - $coverageFailures.Add("Whole-engine line coverage $overallLineCoverage% is below the required $MinimumWholeEngineLineCoverage%.") + $coverageFailures.Add("Whole-core line coverage $overallLineCoverage% is below the required $MinimumWholeEngineLineCoverage%.") } if ($coreLineCoverage -lt $MinimumLineCoverage) { $coverageFailures.Add("Protocol-core line coverage $coreLineCoverage% is below the required $MinimumLineCoverage%.") From e50700649196c2a549265e1581f30d4d6f9cf7ff Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:28:58 +0700 Subject: [PATCH 30/61] Build CI against the paired ARIEC61850 sibling branch --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4b18a5..61bdb41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: permissions: contents: read +env: + ARIEC61850_REF: agent/sv-core-unification + jobs: public-content: name: Validate licensing, provenance, and public wording @@ -43,9 +46,26 @@ jobs: name: Build, test, and validate site needs: public-content runs-on: windows-2025 + env: + ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout + - name: Checkout ARSVIN + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Checkout sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: masarray/ARIEC61850 + ref: ${{ env.ARIEC61850_REF }} + path: ARIEC61850 + + - name: Record paired engine revision + shell: pwsh + run: | + $engineSha = git -C "$env:ARIEC61850_ROOT" rev-parse HEAD + "ARIEC61850_REF=$env:ARIEC61850_REF" | Out-File artifacts-engine-revision.txt -Encoding utf8 + "ARIEC61850_SHA=$engineSha" | Add-Content artifacts-engine-revision.txt + Get-Content artifacts-engine-revision.txt - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -67,16 +87,17 @@ jobs: shell: pwsh run: .\scripts\validate-public-site.ps1 -SiteRoot artifacts/public-site - - name: Restore locked NuGet dependency graph - run: dotnet restore ARSVIN.sln --locked-mode + - name: Restore dependency graph + run: dotnet restore ARSVIN.sln - - name: Upload committed NuGet lock evidence + - name: Upload committed NuGet lock and engine revision evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: arsvin-nuget-lockfiles + name: arsvin-dependency-evidence path: | src/**/packages.lock.json tests/**/packages.lock.json + artifacts-engine-revision.txt if-no-files-found: error retention-days: 7 @@ -86,7 +107,7 @@ jobs: - name: Build Subscriber with warnings as errors run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror - - name: Test with whole-engine and protocol-core coverage gates + - name: Test against sibling ARIEC61850 with coverage gates shell: pwsh run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore From 68ec1da9915316e2a9ff727c2513cdc869d90e77 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:29:26 +0700 Subject: [PATCH 31/61] Analyze ARSVIN with the paired ARIEC61850 sibling --- .github/workflows/codeql.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c527244..5b44a67 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,13 +12,25 @@ permissions: security-events: write contents: read +env: + ARIEC61850_REF: agent/sv-core-unification + jobs: analyze: name: Analyze C# solution runs-on: windows-2025 + env: + ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout + - name: Checkout ARSVIN + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Checkout sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: masarray/ARIEC61850 + ref: ${{ env.ARIEC61850_REF }} + path: ARIEC61850 - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -36,11 +48,11 @@ jobs: languages: csharp build-mode: manual - - name: Restore locked solution dependency graph - run: dotnet restore ARSVIN.sln --locked-mode + - name: Restore solution dependency graph + run: dotnet restore ARSVIN.sln - name: Build solution with warnings as errors run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 \ No newline at end of file + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 From 7490901e78b541c5b716baaf64d453a899a2f855 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:30:59 +0700 Subject: [PATCH 32/61] Bootstrap and build against the sibling ARIEC61850 engine --- build.ps1 | 64 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/build.ps1 b/build.ps1 index 48352be..ee1586c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,3 +1,8 @@ +[CmdletBinding()] +param( + [string] $EngineRef = $(if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'agent/sv-core-unification' }) +) + $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest @@ -6,15 +11,60 @@ $solution = Join-Path $root 'ARSVIN.sln' $appProject = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' $subscriberProject = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' + +function Invoke-Checked { + param( + [Parameter(Mandatory)][string] $FilePath, + [Parameter(Mandatory)][string[]] $Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$FilePath $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} function Invoke-DotNet { param([Parameter(Mandatory)][string[]] $Arguments) - & dotnet @Arguments - if ($LASTEXITCODE -ne 0) { - throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + Invoke-Checked -FilePath 'dotnet' -Arguments $Arguments +} + +if (-not (Test-Path $engineProject -PathType Leaf)) { + if ($env:CI -ne 'true') { + throw "ARIEC61850 sibling repository was not found at $engineRoot. Clone https://github.com/masarray/ARIEC61850 beside this repository or set ARIEC61850_ROOT." + } + + Write-Host "==> CI bootstrap: cloning ARIEC61850 ref $EngineRef into $engineRoot" + $engineParent = Split-Path -Parent $engineRoot + New-Item -ItemType Directory -Path $engineParent -Force | Out-Null + if (Test-Path $engineRoot) { + Remove-Item $engineRoot -Recurse -Force } + Invoke-Checked -FilePath 'git' -Arguments @( + 'clone', '--depth', '1', '--branch', $EngineRef, + 'https://github.com/masarray/ARIEC61850.git', $engineRoot + ) +} + +$env:ARIEC61850_ROOT = $engineRoot +$env:ARIEC61850_REF = $EngineRef +if ($env:GITHUB_ENV) { + "ARIEC61850_ROOT=$engineRoot" | Add-Content $env:GITHUB_ENV + "ARIEC61850_REF=$EngineRef" | Add-Content $env:GITHUB_ENV } +$engineSha = Invoke-Expression "git -C `"$engineRoot`" rev-parse HEAD" +Write-Host "==> ARIEC61850 root: $engineRoot" +Write-Host "==> ARIEC61850 ref: $EngineRef" +Write-Host "==> ARIEC61850 commit: $engineSha" + Write-Host '==> Verifying current license, provenance, and public wording' & python (Join-Path $root 'scripts\verify-current-license.py') if ($LASTEXITCODE -ne 0) { @@ -27,8 +77,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Public terminology neutrality validation failed.' } -Write-Host '==> Restoring locked solution dependency graph' -Invoke-DotNet -Arguments @('restore', $solution, '--locked-mode') +Write-Host '==> Restoring application and sibling-engine dependency graph' +Invoke-DotNet -Arguments @('restore', $solution) Write-Host '==> Building ARSVIN Publisher' Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore', '-warnaserror') @@ -36,7 +86,7 @@ Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore' Write-Host '==> Building ArSubsv Subscriber' Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore', '-warnaserror') -Write-Host '==> Running tests' +Write-Host '==> Running integration regression tests against ARIEC61850' Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') -Write-Host '==> Build completed successfully' \ No newline at end of file +Write-Host '==> Build completed successfully' From 3a5424f1965ca95c94ac66f402c22f92847980fe Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:36:28 +0700 Subject: [PATCH 33/61] Allow paired sibling restore during the unmerged migration --- Directory.Build.props | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9f98f9a..d1dd7af 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,13 @@ true true true - true + + false 0.4.0 $(GITHUB_SHA) true From 1d6c6348bb8265da72139980107179c444927ffd Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:37:21 +0700 Subject: [PATCH 34/61] Publish paired ARSVIN and ARIEC61850 builds with engine provenance --- scripts/publish-release.ps1 | 51 ++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/scripts/publish-release.ps1 b/scripts/publish-release.ps1 index 6ee0250..397a18e 100644 --- a/scripts/publish-release.ps1 +++ b/scripts/publish-release.ps1 @@ -18,6 +18,13 @@ $solution = Join-Path $root 'ARSVIN.sln' $publisherProject = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' $subscriberProject = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' $normalizedVersion = ($Version.Trim() -replace '^[vV]', '') if ($normalizedVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') { @@ -36,6 +43,19 @@ function Invoke-DotNet { } } +function Invoke-GitText { + param( + [Parameter(Mandatory)][string] $Repository, + [Parameter(Mandatory)][string[]] $Arguments + ) + + $output = & git -C $Repository @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C $Repository $($Arguments -join ' ') failed.`n$($output -join [Environment]::NewLine)" + } + return ($output -join '').Trim() +} + function Reset-Directory { param([Parameter(Mandatory)][string] $Path) if (Test-Path $Path) { @@ -86,7 +106,8 @@ function Copy-ReleaseDocumentation { 'known-limitations.md', 'safety-boundaries.md', 'subscriber-verification-app.md', - 'arsubsv-sv-scout-companion.md' + 'arsubsv-sv-scout-companion.md', + 'engine-integration.md' ) foreach ($document in $documents) { @@ -112,20 +133,32 @@ function Copy-ReleaseDocumentation { } } +if (-not (Test-Path $engineProject -PathType Leaf)) { + throw "ARIEC61850 sibling project was not found at $engineProject. Run build.ps1 first or clone the paired engine repository beside ARSVIN." +} + +$sourceCommit = Invoke-GitText -Repository $root -Arguments @('rev-parse', 'HEAD') +$engineCommit = Invoke-GitText -Repository $engineRoot -Arguments @('rev-parse', 'HEAD') +$engineRef = if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'local-checkout' } + +Write-Host "==> ARSVIN source commit: $sourceCommit" +Write-Host "==> ARIEC61850 ref: $engineRef" +Write-Host "==> ARIEC61850 commit: $engineCommit" + Reset-Directory $releaseRoot Reset-Directory $tempRoot Reset-Directory $portableRoot Reset-Directory $installerInput if (-not $SkipTests) { - Write-Host '==> Restoring locked solution graph and testing' - Invoke-DotNet -Arguments @('restore', $solution, '--locked-mode') + Write-Host '==> Restoring paired application/engine graph and testing' + Invoke-DotNet -Arguments @('restore', $solution) Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') } -Write-Host "==> Restoring locked publish graph for $Runtime" -Invoke-DotNet -Arguments @('restore', $publisherProject, '-r', $Runtime, '--locked-mode') -Invoke-DotNet -Arguments @('restore', $subscriberProject, '-r', $Runtime, '--locked-mode') +Write-Host "==> Restoring paired publish graph for $Runtime" +Invoke-DotNet -Arguments @('restore', $publisherProject, '-r', $Runtime) +Invoke-DotNet -Arguments @('restore', $subscriberProject, '-r', $Runtime) $commonPublishArguments = @( '-c', 'Release', @@ -175,11 +208,15 @@ Version: $normalizedVersion Runtime: $Runtime Publisher: ARSVIN.exe Subscriber: ArSubsv.exe +ARSVIN source commit: $sourceCommit +ARIEC61850 ref: $engineRef +ARIEC61850 source commit: $engineCommit Community license: GPL-3.0-or-later Commercial rights: separate negotiated agreement only "@ Set-Content -Path (Join-Path $portableRoot 'VERSION.txt') -Value $versionFile -Encoding utf8 Set-Content -Path (Join-Path $installerInput 'VERSION.txt') -Value $versionFile -Encoding utf8 +Set-Content -Path (Join-Path $releaseRoot 'ENGINE-REVISION.txt') -Value $versionFile -Encoding utf8 $portableZip = Join-Path $releaseRoot 'ARSVIN-win-x64-portable.zip' Compress-Archive -Path (Join-Path $portableRoot '*') -DestinationPath $portableZip -CompressionLevel Optimal -Force @@ -187,4 +224,4 @@ Compress-Archive -Path (Join-Path $portableRoot '*') -DestinationPath $portableZ Write-Host '==> Portable release artifacts' Get-ChildItem $releaseRoot -File | Select-Object Name, Length | Format-Table -AutoSize Write-Host "Release output: $releaseRoot" -Write-Host "Installer input: $installerInput" \ No newline at end of file +Write-Host "Installer input: $installerInput" From 45a3f5d94acb2cdf0991f0784e8d3d13222dfda7 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:38:23 +0700 Subject: [PATCH 35/61] Record sibling ARIEC61850 provenance in the release SBOM --- scripts/generate-sbom.ps1 | 92 ++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/scripts/generate-sbom.ps1 b/scripts/generate-sbom.ps1 index 1b11794..98ec446 100644 --- a/scripts/generate-sbom.ps1 +++ b/scripts/generate-sbom.ps1 @@ -27,8 +27,6 @@ function New-DeterministicUuidUrn { $uuidBytes = [byte[]]::new(16) [Array]::Copy($hash, $uuidBytes, $uuidBytes.Length) - - # RFC 4122 variant with a deterministic version-5-style identifier. $uuidBytes[6] = [byte](($uuidBytes[6] -band 0x0F) -bor 0x50) $uuidBytes[8] = [byte](($uuidBytes[8] -band 0x3F) -bor 0x80) @@ -44,7 +42,31 @@ function New-DeterministicUuidUrn { return "urn:uuid:$uuid" } +function Invoke-GitText { + param( + [Parameter(Mandatory)][string] $Repository, + [Parameter(Mandatory)][string[]] $Arguments + ) + + $output = & git -C $Repository @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "git -C $Repository $($Arguments -join ' ') failed.`n$($output -join [Environment]::NewLine)" + } + return ($output -join '').Trim() +} + $root = Split-Path -Parent $PSScriptRoot +$engineRoot = if ($env:ARIEC61850_ROOT) { + [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) +} +else { + [System.IO.Path]::GetFullPath((Join-Path $root '..\ARIEC61850')) +} +$engineProject = Join-Path $engineRoot 'src\AR.Iec61850\AR.Iec61850.csproj' +if (-not (Test-Path $engineProject -PathType Leaf)) { + throw "ARIEC61850 sibling project was not found at $engineProject." +} + $applicationProjects = @( [ordered]@{ Name = 'ARSVIN Publisher' @@ -66,23 +88,15 @@ elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) { $outputDirectory = Split-Path -Parent $OutputPath New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null -$sourceCommitOutput = & git -C $root rev-parse HEAD 2>&1 -if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the source commit.`n$($sourceCommitOutput -join [Environment]::NewLine)" -} -$sourceCommit = ($sourceCommitOutput -join '').Trim() - -$sourceTimestampOutput = & git -C $root show -s --format=%cI HEAD 2>&1 -if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the source commit timestamp.`n$($sourceTimestampOutput -join [Environment]::NewLine)" -} +$sourceCommit = Invoke-GitText -Repository $root -Arguments @('rev-parse', 'HEAD') +$engineCommit = Invoke-GitText -Repository $engineRoot -Arguments @('rev-parse', 'HEAD') +$engineRef = if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'local-checkout' } $sourceTimestamp = [DateTimeOffset]::Parse( - ($sourceTimestampOutput -join '').Trim(), + (Invoke-GitText -Repository $root -Arguments @('show', '-s', '--format=%cI', 'HEAD')), [System.Globalization.CultureInfo]::InvariantCulture ).ToUniversalTime().ToString('o') -$serialNumber = New-DeterministicUuidUrn -Value "https://github.com/masarray/arsvin|$Version|$sourceCommit" - +$serialNumber = New-DeterministicUuidUrn -Value "https://github.com/masarray/arsvin|$Version|$sourceCommit|$engineCommit" $packages = @{} foreach ($applicationProject in $applicationProjects) { @@ -172,7 +186,7 @@ if ($testOnlyPackages.Count -gt 0) { throw "Release SBOM unexpectedly contains test-only packages: $names" } -$components = foreach ($package in $sortedPackages) { +$nugetComponents = foreach ($package in $sortedPackages) { $escapedId = [Uri]::EscapeDataString([string] $package.Id) $escapedVersion = [Uri]::EscapeDataString([string] $package.Version) $purl = "pkg:nuget/$escapedId@$escapedVersion" @@ -197,6 +211,27 @@ $components = foreach ($package in $sortedPackages) { } } +$enginePurl = "pkg:generic/ARIEC61850@$engineCommit" +$engineComponent = [ordered]@{ + type = 'library' + 'bom-ref' = $enginePurl + name = 'ARIEC61850' + version = $engineCommit + purl = $enginePurl + licenses = @( + [ordered]@{ + license = [ordered]@{ id = 'GPL-3.0-or-later' } + } + ) + properties = @( + [ordered]@{ name = 'arsvin:source-repository'; value = 'https://github.com/masarray/ARIEC61850' }, + [ordered]@{ name = 'arsvin:source-ref'; value = $engineRef }, + [ordered]@{ name = 'arsvin:source-commit'; value = $engineCommit }, + [ordered]@{ name = 'arsvin:dependency-scope'; value = 'direct-source-project' } + ) +} + +$allComponents = @($engineComponent) + @($nugetComponents) $sbom = [ordered]@{ bomFormat = 'CycloneDX' specVersion = '1.5' @@ -210,7 +245,7 @@ $sbom = [ordered]@{ type = 'application' author = 'ARSVIN project' name = 'generate-sbom.ps1' - version = '1.2.0' + version = '2.0.0' } ) } @@ -221,24 +256,18 @@ $sbom = [ordered]@{ version = $Version licenses = @( [ordered]@{ - license = [ordered]@{ - id = 'Apache-2.0' - } + license = [ordered]@{ id = 'GPL-3.0-or-later' } } ) properties = @( - [ordered]@{ - name = 'arsvin:source-commit' - value = $sourceCommit - }, - [ordered]@{ - name = 'arsvin:included-applications' - value = ($applicationProjects.Name -join ', ') - } + [ordered]@{ name = 'arsvin:source-commit'; value = $sourceCommit }, + [ordered]@{ name = 'arsvin:engine-ref'; value = $engineRef }, + [ordered]@{ name = 'arsvin:engine-commit'; value = $engineCommit }, + [ordered]@{ name = 'arsvin:included-applications'; value = ($applicationProjects.Name -join ', ') } ) } } - components = @($components) + components = $allComponents } $json = $sbom | ConvertTo-Json -Depth 20 @@ -250,7 +279,8 @@ $json = $sbom | ConvertTo-Json -Depth 20 $written = Get-Item $OutputPath Write-Host "==> CycloneDX SBOM written: $($written.FullName)" -Write-Host " Source commit: $sourceCommit" +Write-Host " ARSVIN source commit: $sourceCommit" +Write-Host " ARIEC61850 source commit: $engineCommit" Write-Host " Serial number: $serialNumber" -Write-Host " Components: $($components.Count)" +Write-Host " Components: $($allComponents.Count)" Write-Host " Size: $($written.Length) bytes" From 85bd453dd8317d71be547513eb9d5c94ec3ba3ea Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:39:50 +0700 Subject: [PATCH 36/61] Keep paired engine provenance inside release packages and SBOM --- scripts/publish-release.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/publish-release.ps1 b/scripts/publish-release.ps1 index 397a18e..341ecf1 100644 --- a/scripts/publish-release.ps1 +++ b/scripts/publish-release.ps1 @@ -216,7 +216,6 @@ Commercial rights: separate negotiated agreement only "@ Set-Content -Path (Join-Path $portableRoot 'VERSION.txt') -Value $versionFile -Encoding utf8 Set-Content -Path (Join-Path $installerInput 'VERSION.txt') -Value $versionFile -Encoding utf8 -Set-Content -Path (Join-Path $releaseRoot 'ENGINE-REVISION.txt') -Value $versionFile -Encoding utf8 $portableZip = Join-Path $releaseRoot 'ARSVIN-win-x64-portable.zip' Compress-Archive -Path (Join-Path $portableRoot '*') -DestinationPath $portableZip -CompressionLevel Optimal -Force From 74b1f353db8e41905d98958f3cb9d7d5aca98a70 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:40:28 +0700 Subject: [PATCH 37/61] Enforce ARIEC61850 ownership in derived applications --- scripts/validate-engine-ownership.py | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/validate-engine-ownership.py diff --git a/scripts/validate-engine-ownership.py b/scripts/validate-engine-ownership.py new file mode 100644 index 0000000..5e4a8f5 --- /dev/null +++ b/scripts/validate-engine-ownership.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Validate that ARSVIN applications consume ARIEC61850 instead of an embedded engine. + +The legacy src/ARSVIN.Engine directory may remain temporarily during migration, but it must +not be referenced by active projects or the solution. Reusable AR.Iec61850 namespaces must +not be reintroduced in application source folders. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ACTIVE_PROJECTS = ( + ROOT / "src" / "ARSVIN" / "ARSVIN.csproj", + ROOT / "src" / "ARSVIN.Subscriber" / "ARSVIN.Subscriber.csproj", + ROOT / "tests" / "ARSVIN.Tests" / "ARSVIN.Tests.csproj", +) +SOLUTION = ROOT / "ARSVIN.sln" +LEGACY_ENGINE = ROOT / "src" / "ARSVIN.Engine" + + +def read(path: Path) -> str: + if not path.is_file(): + raise RuntimeError(f"Required file is missing: {path.relative_to(ROOT)}") + return path.read_text(encoding="utf-8-sig") + + +def main() -> int: + errors: list[str] = [] + + for project in ACTIVE_PROJECTS: + text = read(project) + relative = project.relative_to(ROOT) + if "ARSVIN.Engine" in text: + errors.append(f"{relative}: active project still references ARSVIN.Engine") + if "$(ARIEC61850_CORE_PROJECT)" not in text: + errors.append(f"{relative}: missing ARIEC61850 core ProjectReference") + if "$(ARIEC61850_NPCAP_PROJECT)" not in text: + errors.append(f"{relative}: missing ARIEC61850 Npcap ProjectReference") + + solution_text = read(SOLUTION) + if "ARSVIN.Engine" in solution_text: + errors.append("ARSVIN.sln: embedded ARSVIN.Engine is still an active solution project") + + application_roots = (ROOT / "src" / "ARSVIN", ROOT / "src" / "ARSVIN.Subscriber") + for application_root in application_roots: + for source in application_root.rglob("*.cs"): + text = source.read_text(encoding="utf-8-sig", errors="replace") + if "namespace AR.Iec61850" in text: + errors.append( + f"{source.relative_to(ROOT)}: reusable AR.Iec61850 namespace belongs in the sibling engine repository" + ) + + legacy_count = 0 + if LEGACY_ENGINE.is_dir(): + legacy_count = sum(1 for path in LEGACY_ENGINE.rglob("*.cs") if path.is_file()) + + report_lines = [ + "ARSVIN engine ownership validation", + "active engine: sibling masarray/ARIEC61850", + f"active application projects checked: {len(ACTIVE_PROJECTS)}", + f"inactive legacy embedded C# files remaining: {legacy_count}", + ] + + if errors: + report_lines.append("result: FAILED") + report_lines.extend(f"ERROR: {error}" for error in errors) + else: + report_lines.append("result: PASSED") + + report = "\n".join(report_lines) + "\n" + print(report, end="") + + artifact = ROOT / "artifacts" / "engine-ownership-report.txt" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text(report, encoding="utf-8") + + return 1 if errors else 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) From 19a4c2942ff8e0639abbe98deb7083bf2fbcfecc Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:40:59 +0700 Subject: [PATCH 38/61] Validate engine ownership and safely resolve paired engine provenance --- build.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/build.ps1 b/build.ps1 index ee1586c..a417478 100644 --- a/build.ps1 +++ b/build.ps1 @@ -60,7 +60,12 @@ if ($env:GITHUB_ENV) { "ARIEC61850_REF=$EngineRef" | Add-Content $env:GITHUB_ENV } -$engineSha = Invoke-Expression "git -C `"$engineRoot`" rev-parse HEAD" +$engineShaOutput = & git -C $engineRoot rev-parse HEAD 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the paired ARIEC61850 commit.`n$($engineShaOutput -join [Environment]::NewLine)" +} +$engineSha = ($engineShaOutput -join '').Trim() + Write-Host "==> ARIEC61850 root: $engineRoot" Write-Host "==> ARIEC61850 ref: $EngineRef" Write-Host "==> ARIEC61850 commit: $engineSha" @@ -77,6 +82,12 @@ if ($LASTEXITCODE -ne 0) { throw 'Public terminology neutrality validation failed.' } +Write-Host '==> Validating ARIEC61850 engine ownership boundary' +& python (Join-Path $root 'scripts\validate-engine-ownership.py') +if ($LASTEXITCODE -ne 0) { + throw 'Engine ownership validation failed.' +} + Write-Host '==> Restoring application and sibling-engine dependency graph' Invoke-DotNet -Arguments @('restore', $solution) From d8012b234633c248030427393c9a290cd55d09bb Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:41:41 +0700 Subject: [PATCH 39/61] Enforce engine ownership in paired CI --- .github/workflows/ci.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61bdb41..b34d578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ env: jobs: public-content: - name: Validate licensing, provenance, and public wording + name: Validate licensing, provenance, wording, and engine ownership runs-on: ubuntu-24.04 steps: - name: Checkout @@ -29,17 +29,24 @@ jobs: continue-on-error: true run: python3 scripts/validate-public-neutrality.py - - name: Upload terminology validation evidence + - name: Validate ARIEC61850 engine ownership boundary + id: ownership + continue-on-error: true + run: python3 scripts/validate-engine-ownership.py + + - name: Upload public and ownership validation evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: arsvin-public-neutrality-report - path: artifacts/public-neutrality-report.txt + name: arsvin-public-and-engine-ownership-reports + path: | + artifacts/public-neutrality-report.txt + artifacts/engine-ownership-report.txt if-no-files-found: error retention-days: 14 - - name: Enforce terminology validation result - if: steps.neutrality.outcome == 'failure' + - name: Enforce public and ownership validation results + if: steps.neutrality.outcome == 'failure' || steps.ownership.outcome == 'failure' run: exit 1 build-test: @@ -80,6 +87,9 @@ jobs: - name: Verify current licensing and public wording run: python scripts/verify-current-license.py + - name: Validate engine ownership boundary + run: python scripts/validate-engine-ownership.py + - name: Build public site and HTML documentation run: python scripts/build-public-site.py --output artifacts/public-site @@ -90,7 +100,7 @@ jobs: - name: Restore dependency graph run: dotnet restore ARSVIN.sln - - name: Upload committed NuGet lock and engine revision evidence + - name: Upload NuGet lock and paired engine revision evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: arsvin-dependency-evidence From c992b91b2c3211edd47f507d2d80bb2826fcbe3e Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:44:36 +0700 Subject: [PATCH 40/61] Allow application namespaces while blocking reusable engine domains --- scripts/validate-engine-ownership.py | 29 ++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/scripts/validate-engine-ownership.py b/scripts/validate-engine-ownership.py index 5e4a8f5..a45d2cf 100644 --- a/scripts/validate-engine-ownership.py +++ b/scripts/validate-engine-ownership.py @@ -2,12 +2,14 @@ """Validate that ARSVIN applications consume ARIEC61850 instead of an embedded engine. The legacy src/ARSVIN.Engine directory may remain temporarily during migration, but it must -not be referenced by active projects or the solution. Reusable AR.Iec61850 namespaces must -not be reintroduced in application source folders. +not be referenced by active projects or the solution. Reusable protocol namespaces must not +be reintroduced in application source folders. Application-specific namespaces such as +AR.Iec61850.SvPublisher remain presentation ownership and are allowed. """ from __future__ import annotations +import re import sys from pathlib import Path @@ -20,6 +22,19 @@ ) SOLUTION = ROOT / "ARSVIN.sln" LEGACY_ENGINE = ROOT / "src" / "ARSVIN.Engine" +ENGINE_NAMESPACE_PREFIXES = ( + "AR.Iec61850.Asn1", + "AR.Iec61850.Capture", + "AR.Iec61850.Diagnostics", + "AR.Iec61850.Ethernet", + "AR.Iec61850.Goose", + "AR.Iec61850.Mms", + "AR.Iec61850.Reporting", + "AR.Iec61850.SampledValues", + "AR.Iec61850.Scl", + "AR.Iec61850.Transports", +) +NAMESPACE_PATTERN = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE) def read(path: Path) -> str: @@ -49,10 +64,12 @@ def main() -> int: for application_root in application_roots: for source in application_root.rglob("*.cs"): text = source.read_text(encoding="utf-8-sig", errors="replace") - if "namespace AR.Iec61850" in text: - errors.append( - f"{source.relative_to(ROOT)}: reusable AR.Iec61850 namespace belongs in the sibling engine repository" - ) + for match in NAMESPACE_PATTERN.finditer(text): + namespace = match.group(1) + if namespace.startswith(ENGINE_NAMESPACE_PREFIXES): + errors.append( + f"{source.relative_to(ROOT)}: reusable namespace {namespace} belongs in the sibling engine repository" + ) legacy_count = 0 if LEGACY_ENGINE.is_dir(): From 03ef6747f8dba83c803789082f14762052e09370 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:45:44 +0700 Subject: [PATCH 41/61] Document paired local engine integration and testing --- docs/engine-integration.md | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/engine-integration.md diff --git a/docs/engine-integration.md b/docs/engine-integration.md new file mode 100644 index 0000000..a645aec --- /dev/null +++ b/docs/engine-integration.md @@ -0,0 +1,87 @@ +# ARIEC61850 sibling-engine integration + +## Ownership + +ARSVIN Publisher and ArSubsv Subscriber are derived applications. Reusable IEC 61850 protocol, Sampled Values, SCL, PCAP, transport, measurement, diagnostics, and evidence behavior is owned by the sibling [`masarray/ARIEC61850`](https://github.com/masarray/ARIEC61850) repository. + +The application repository owns WPF presentation, commands, workflow orchestration, application settings, branding, and packaging. The inactive `src/ARSVIN.Engine` directory is temporary migration material and is not an active project reference. + +## Paired local layout + +```text +C:\Git\ +├── ARIEC61850\ +└── arsvin\ +``` + +For the unmerged integration test: + +```powershell +git clone https://github.com/masarray/ARIEC61850.git C:\Git\ARIEC61850 +git -C C:\Git\ARIEC61850 switch agent/sv-core-unification + +git clone https://github.com/masarray/arsvin.git C:\Git\arsvin +git -C C:\Git\arsvin switch agent/ariec-sibling-integration +``` + +Then build the paired graph: + +```powershell +cd C:\Git\arsvin +.\build.ps1 +``` + +The default sibling path can be overridden: + +```powershell +$env:ARIEC61850_ROOT = 'D:\Engineering\ARIEC61850' +.\build.ps1 +``` + +or for a direct MSBuild invocation: + +```powershell +dotnet build .\src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj ` + -c Release ` + -p:ARIEC61850_ROOT='D:\Engineering\ARIEC61850' +``` + +## Paired test sequence + +1. Build and test `ARIEC61850` first. +2. Build ARSVIN Publisher and ArSubsv against that exact checkout. +3. Run Publisher dry-run and PCAP generation. +4. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. +5. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. +6. Run authorized isolated live capture/transmit tests only after offline tests pass. +7. Record both Git commit SHAs in every test note. + +Commands: + +```powershell +cd C:\Git\ARIEC61850 +dotnet restore .\ARIEC61850.sln +dotnet build .\ARIEC61850.sln -c Release +dotnet test .\ARIEC61850.sln -c Release --no-build + +cd C:\Git\arsvin +.\build.ps1 +``` + +## Migration rules + +- New reusable `AR.Iec61850.*` protocol or analysis code goes to ARIEC61850 first. +- Application projects must not reference `ARSVIN.Engine`. +- Manufacturer identity must not select an SV parser, dataset order, scaling, quality interpretation, timebase, or health result. +- Engine changes require deterministic engine tests in ARIEC61850 and paired application regression tests in ARSVIN. +- CI records the exact paired engine commit; a moving unrecorded engine revision is not a reproducible release input. +- Lock files remain temporarily unlocked in this draft migration. They must be regenerated after the reviewed ARIEC61850 commit is pinned. + +## Current draft pairing + +| Repository | Branch | Pull request | +|---|---|---| +| ARIEC61850 | `agent/sv-core-unification` | `masarray/ARIEC61850#45` | +| ARSVIN | `agent/ariec-sibling-integration` | `masarray/arsvin#51` | + +Neither pull request is intended for merge until paired local testing, CI, CodeQL, packaging, and offline PCAP/SCL regression tests are complete. From b73c0c9e71bbeecfc6beccd671e763d3f16d7014 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:47:18 +0700 Subject: [PATCH 42/61] Preserve paired application build diagnostics as CI artifacts --- .github/workflows/ci.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b34d578..e437446 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,10 +112,29 @@ jobs: retention-days: 7 - name: Build Publisher with warnings as errors - run: dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null + dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror 2>&1 | + Tee-Object -FilePath artifacts/build-logs/publisher-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build Subscriber with warnings as errors - run: dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null + dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror 2>&1 | + Tee-Object -FilePath artifacts/build-logs/subscriber-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload paired build diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: arsvin-paired-build-logs + path: artifacts/build-logs + if-no-files-found: warn + retention-days: 14 - name: Test against sibling ARIEC61850 with coverage gates shell: pwsh From def7883c5141fa825c15989c5f246b41240a9825 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:49:55 +0700 Subject: [PATCH 43/61] Capture paired restore failures in CI artifacts --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e437446..c558748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,12 @@ jobs: run: .\scripts\validate-public-site.ps1 -SiteRoot artifacts/public-site - name: Restore dependency graph - run: dotnet restore ARSVIN.sln + shell: pwsh + run: | + New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null + dotnet restore ARSVIN.sln 2>&1 | + Tee-Object -FilePath artifacts/build-logs/restore.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload NuGet lock and paired engine revision evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -114,7 +119,6 @@ jobs: - name: Build Publisher with warnings as errors shell: pwsh run: | - New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null dotnet build src/ARSVIN/ARSVIN.csproj -c Release --no-restore -warnaserror 2>&1 | Tee-Object -FilePath artifacts/build-logs/publisher-build.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -122,12 +126,11 @@ jobs: - name: Build Subscriber with warnings as errors shell: pwsh run: | - New-Item -ItemType Directory -Path artifacts/build-logs -Force | Out-Null dotnet build src/ARSVIN.Subscriber/ARSVIN.Subscriber.csproj -c Release --no-restore -warnaserror 2>&1 | Tee-Object -FilePath artifacts/build-logs/subscriber-build.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Upload paired build diagnostics + - name: Upload paired restore and build diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: From 871e7cf0acfb0dd78dbe3fabd70fbcde8eb16e9b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:51:28 +0700 Subject: [PATCH 44/61] Use true sibling checkouts for paired CI builds --- .github/workflows/ci.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c558748..bd7a785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,11 +53,16 @@ jobs: name: Build, test, and validate site needs: public-content runs-on: windows-2025 + defaults: + run: + working-directory: arsvin env: ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout ARSVIN + - name: Checkout ARSVIN into paired workspace uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: arsvin - name: Checkout sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -80,9 +85,9 @@ jobs: dotnet-version: 8.0.4xx cache: true cache-dependency-path: | - Directory.Packages.props - src/**/packages.lock.json - tests/**/packages.lock.json + arsvin/Directory.Packages.props + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json - name: Verify current licensing and public wording run: python scripts/verify-current-license.py @@ -110,9 +115,9 @@ jobs: with: name: arsvin-dependency-evidence path: | - src/**/packages.lock.json - tests/**/packages.lock.json - artifacts-engine-revision.txt + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json + arsvin/artifacts-engine-revision.txt if-no-files-found: error retention-days: 7 @@ -135,7 +140,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: arsvin-paired-build-logs - path: artifacts/build-logs + path: arsvin/artifacts/build-logs if-no-files-found: warn retention-days: 14 @@ -148,7 +153,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: arsvin-test-evidence - path: artifacts/test-results + path: arsvin/artifacts/test-results if-no-files-found: warn retention-days: 14 From 6c6091fbf3761c3a7034186c804e124f5bdd8376 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:51:57 +0700 Subject: [PATCH 45/61] Use true sibling checkouts for CodeQL analysis --- .github/workflows/codeql.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5b44a67..2bc9652 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,11 +19,16 @@ jobs: analyze: name: Analyze C# solution runs-on: windows-2025 + defaults: + run: + working-directory: arsvin env: ARIEC61850_ROOT: ${{ github.workspace }}\ARIEC61850 steps: - - name: Checkout ARSVIN + - name: Checkout ARSVIN into paired workspace uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: arsvin - name: Checkout sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -38,9 +43,9 @@ jobs: dotnet-version: 8.0.4xx cache: true cache-dependency-path: | - Directory.Packages.props - src/**/packages.lock.json - tests/**/packages.lock.json + arsvin/Directory.Packages.props + arsvin/src/**/packages.lock.json + arsvin/tests/**/packages.lock.json - name: Initialize CodeQL uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 @@ -48,10 +53,10 @@ jobs: languages: csharp build-mode: manual - - name: Restore solution dependency graph + - name: Restore paired solution dependency graph run: dotnet restore ARSVIN.sln - - name: Build solution with warnings as errors + - name: Build paired solution with warnings as errors run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror - name: Perform CodeQL Analysis From 2416043c8de100cc38ba0d2a31d0b371b938f525 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:53:40 +0700 Subject: [PATCH 46/61] Remove migrated generic ASDU inspector from embedded engine --- .../Analysis/SvGenericAsduInspector.cs | 58 ------------------- 1 file changed, 58 deletions(-) delete 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 deleted file mode 100644 index da5ef74..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericAsduInspector.cs +++ /dev/null @@ -1,58 +0,0 @@ -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 456b5d80c7169038a5defd754d4a09e1336f7274 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:53:52 +0700 Subject: [PATCH 47/61] Remove migrated generic payload inspector from embedded engine --- .../Analysis/SvGenericPayloadInspector.cs | 162 ------------------ 1 file changed, 162 deletions(-) delete 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 deleted file mode 100644 index 6b201c7..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Analysis/SvGenericPayloadInspector.cs +++ /dev/null @@ -1,162 +0,0 @@ -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 phase channels, 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 45e9ccab2f858fcbba250570c7cdab08a501c876 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 09:54:44 +0700 Subject: [PATCH 48/61] Restore inactive embedded scaling file to main baseline --- .../Measurements/SvEngineeringScaling.cs | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs index 2d5fc8a..0e4d91f 100644 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs +++ b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/Measurements/SvEngineeringScaling.cs @@ -29,13 +29,7 @@ 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 } @@ -66,9 +60,8 @@ public sealed record SvEngineeringScaleEvidence } /// -/// 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. +/// 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. /// public static class SvEngineeringScaleResolver { @@ -82,31 +75,27 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) var domain = ResolveDomain(evidence.Channel, evidence.Kind); if (domain == SvMeasurementDomain.Unknown) - 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."); - } + return SvEngineeringScale.RawOnly("The channel could not be classified as current or voltage."); var fixedLayout = evidence.IsFixedFourCurrentFourVoltageLayout && evidence.AnalogChannelCount == 8 && evidence.PayloadBytesPerAsdu == 64; if (!fixedLayout) - { - 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 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 domain switch { @@ -114,16 +103,16 @@ public static SvEngineeringScale Resolve(SvEngineeringScaleEvidence evidence) { Multiplier = CurrentAmperesPerCount, Unit = "A", - Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, - Confidence = SvEngineeringScaleConfidence.SclBacked, + Source = source, + Confidence = confidence, Reason = reason }, SvMeasurementDomain.Voltage => new SvEngineeringScale { Multiplier = VoltageVoltsPerCount, Unit = "V", - Source = SvEngineeringScaleSource.SclBackedLegacy92LeStyle, - Confidence = SvEngineeringScaleConfidence.SclBacked, + Source = source, + Confidence = confidence, Reason = reason }, _ => SvEngineeringScale.RawOnly("Unsupported measurement domain.") From b1d0800a6aaae201d3fc8e0513ccc3e6493fd4cf Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:06:08 +0700 Subject: [PATCH 49/61] Document migrated publisher and subscriber core contracts --- docs/engine-integration.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/engine-integration.md b/docs/engine-integration.md index a645aec..bb022e9 100644 --- a/docs/engine-integration.md +++ b/docs/engine-integration.md @@ -6,6 +6,20 @@ ARSVIN Publisher and ArSubsv Subscriber are derived applications. Reusable IEC 6 The application repository owns WPF presentation, commands, workflow orchestration, application settings, branding, and packaging. The inactive `src/ARSVIN.Engine` directory is temporary migration material and is not an active project reference. +## Migrated reusable contracts + +The paired engine branch now owns the reusable contracts required by both applications: + +- generic ASDU and raw `seqOfData` inspection; +- evidence-gated engineering scaling; +- timebase and `smpCnt` continuity analysis; +- semantic quality decoding and publisher quality encoding; +- explicit CT/VT and measurement-domain context; +- Sampled Values publisher evidence report contracts; +- local transmitter timing-health evidence. + +These contracts are implemented and tested in ARIEC61850. Application code consumes them; it must not maintain a second behavioral copy. + ## Paired local layout ```text From cd44d1f2a6dba09dca8826c4d8dbe779d1a8f51c Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:15:44 +0700 Subject: [PATCH 50/61] Remove migrated Sampled Values quality encoding from embedded engine --- .../SampledValues/SampledValueQuality.cs | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs deleted file mode 100644 index 63f03cc..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValueQuality.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Buffers.Binary; - -namespace AR.Iec61850.SampledValues; - -/// -/// Compact IEC 61850 quality bit helper used by the SV publisher payload builder. -/// The class intentionally exposes only the common simulation knobs needed by a publisher. -/// -public readonly record struct SampledValueQuality( - SampledValueValidity Validity, - bool Overflow = false, - bool OutOfRange = false, - bool BadReference = false, - bool Oscillatory = false, - bool Failure = false, - bool OldData = false, - bool Inconsistent = false, - bool Inaccurate = false, - bool Test = false, - bool OperatorBlocked = false) -{ - public static SampledValueQuality Good { get; } = new(SampledValueValidity.Good); - public static SampledValueQuality Invalid { get; } = new(SampledValueValidity.Invalid); - public static SampledValueQuality Questionable { get; } = new(SampledValueValidity.Questionable); - public static SampledValueQuality TestGood { get; } = new(SampledValueValidity.Good, Test: true); - public static SampledValueQuality OldDataGood { get; } = new(SampledValueValidity.Good, OldData: true); - public static SampledValueQuality OperatorBlockedGood { get; } = new(SampledValueValidity.Good, OperatorBlocked: true); - - public uint ToUInt32() - { - var value = (uint)Validity; - if (Overflow) value |= 1u << 2; - if (OutOfRange) value |= 1u << 3; - if (BadReference) value |= 1u << 4; - if (Oscillatory) value |= 1u << 5; - if (Failure) value |= 1u << 6; - if (OldData) value |= 1u << 7; - if (Inconsistent) value |= 1u << 8; - if (Inaccurate) value |= 1u << 9; - if (Test) value |= 1u << 11; - if (OperatorBlocked) value |= 1u << 12; - return value; - } - - public byte[] ToBytes(int width = 4) - { - if (width <= 0) - return []; - - Span encoded = stackalloc byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(encoded, ToUInt32()); - - if (width == 4) - return encoded.ToArray(); - - var result = new byte[width]; - encoded[..Math.Min(4, width)].CopyTo(result); - return result; - } - - public static SampledValueQuality FromUInt32(uint encoded) - { - var validity = (SampledValueValidity)(encoded & 0x03); - return new SampledValueQuality( - validity, - Overflow: (encoded & (1u << 2)) != 0, - OutOfRange: (encoded & (1u << 3)) != 0, - BadReference: (encoded & (1u << 4)) != 0, - Oscillatory: (encoded & (1u << 5)) != 0, - Failure: (encoded & (1u << 6)) != 0, - OldData: (encoded & (1u << 7)) != 0, - Inconsistent: (encoded & (1u << 8)) != 0, - Inaccurate: (encoded & (1u << 9)) != 0, - Test: (encoded & (1u << 11)) != 0, - OperatorBlocked: (encoded & (1u << 12)) != 0); - } -} - -public enum SampledValueValidity : uint -{ - Good = 0, - Invalid = 1, - Reserved = 2, - Questionable = 3 -} From 3e5a5cb106ee10c8d18ba871ad7a6f766507032c Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:16:05 +0700 Subject: [PATCH 51/61] Remove migrated sample counter policy from embedded engine --- .../SampledValues/SampleCounterPolicy.cs | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs deleted file mode 100644 index cfcd6de..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampleCounterPolicy.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace AR.Iec61850.SampledValues; - -public enum SampleCounterMode -{ - FreeRun, - SecondAligned -} - -public static class SampleCounterPolicy -{ - public static ushort InitialSampleCount(DateTimeOffset timestamp, double sampleRateHz, ushort? wrap, SampleCounterMode mode) - { - if (mode == SampleCounterMode.FreeRun || sampleRateHz <= 0) - return 0; - - var samplesPerSecond = wrap is > 1 ? wrap.Value : sampleRateHz; - if (samplesPerSecond <= 0) - return 0; - - var seconds = timestamp.TimeOfDay.TotalSeconds; - var fraction = seconds - Math.Floor(seconds); - var sample = (long)Math.Floor(fraction * samplesPerSecond); - var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1L; - sample %= modulo; - if (sample < 0) - sample += modulo; - return (ushort)sample; - } - - public static ushort Increment(ushort current, ushort? wrap, int step = 1) - { - if (step <= 0) - return current; - - var modulo = wrap is > 1 ? wrap.Value : ushort.MaxValue + 1; - return (ushort)((current + step) % modulo); - } -} From 97aa6676d05cc8123dd511760e065459034da02b Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:16:22 +0700 Subject: [PATCH 52/61] Remove migrated SV PCAP exporter from embedded engine --- .../SampledValues/SampledValuesPcapExporter.cs | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs deleted file mode 100644 index 6211cf5..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPcapExporter.cs +++ /dev/null @@ -1,13 +0,0 @@ -using AR.Iec61850.Capture; - -namespace AR.Iec61850.SampledValues; - -public static class SampledValuesPcapExporter -{ - public static void WriteGeneratedFrames(string filePath, IEnumerable<(DateTimeOffset Timestamp, byte[] Frame)> frames) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - ArgumentNullException.ThrowIfNull(frames); - PcapWriter.WriteAll(filePath, frames.Select(frame => new PcapPacket(frame.Timestamp, frame.Frame))); - } -} From a86ad6f53838864d1903bc399dca1448d9028d93 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:16:42 +0700 Subject: [PATCH 53/61] Remove migrated SV frame preview from embedded engine --- .../SampledValuesFramePreview.cs | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs deleted file mode 100644 index 5e93ce7..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesFramePreview.cs +++ /dev/null @@ -1,69 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public sealed record SampledValuesFramePreview( - string ControlBlockReference, - string SvId, - string DataSetReference, - ushort AppId, - MacAddress Destination, - VlanTag? Vlan, - ushort NoAsdu, - int PayloadBytesPerAsdu, - double SampleRateHz, - double PublicationRateHz, - int EstimatedEthernetBytes, - double EstimatedBandwidthBitsPerSecond) -{ - public string Summary => - $"{ControlBlockReference}: svID={SvId}, APPID=0x{AppId:X4}, dst={Destination}, " + - $"{(Vlan is null ? "untagged" : $"VID={Vlan.Value.VlanId}/PCP={Vlan.Value.PriorityCodePoint}")}, " + - $"nofASDU={NoAsdu}, sample={SampleRateHz:0.###} fps, publish={PublicationRateHz:0.###} fps, " + - $"payload={PayloadBytesPerAsdu} B/ASDU, frame≈{EstimatedEthernetBytes} B, bw≈{EstimatedBandwidthBitsPerSecond / 1000.0:0.###} kbps"; - - public static SampledValuesFramePreview FromStream(SclSampledValuesStream stream, double sampleRateHz) - { - ArgumentNullException.ThrowIfNull(stream); - var profile = SampledValuesPublisherProfile.Create(stream); - return FromProfile(profile, sampleRateHz); - } - - public static SampledValuesFramePreview FromProfile(SampledValuesPublisherProfile profile, double sampleRateHz) - { - ArgumentNullException.ThrowIfNull(profile); - var noAsdu = profile.AsduPerFrame; - var publicationRateHz = SampledValuesPublisherProfile.ResolvePublicationRate(sampleRateHz, noAsdu); - var estimatedBytes = EstimateEthernetFrameBytes(profile, noAsdu); - return new SampledValuesFramePreview( - profile.Stream.ControlBlockReference, - profile.Stream.SvId, - profile.Stream.DataSetReference, - profile.AppId, - profile.Destination, - profile.Vlan, - noAsdu, - profile.PayloadLayout.PayloadByteLength, - sampleRateHz, - publicationRateHz, - estimatedBytes, - estimatedBytes * 8.0 * publicationRateHz); - } - - public static int EstimateEthernetFrameBytes(SampledValuesPublisherProfile profile, ushort? noAsduOverride = null) - { - ArgumentNullException.ThrowIfNull(profile); - var noAsdu = noAsduOverride ?? profile.AsduPerFrame; - var payloads = Enumerable.Range(0, noAsdu) - .Select(_ => new byte[profile.PayloadLayout.PayloadByteLength]) - .ToArray(); - var frame = profile.BuildEthernetFrame( - MacAddress.Parse("02:00:00:00:00:01"), - sampleCount: 0, - samplePayloads: payloads, - referenceTime: null, - sampleSynchronization: 2); - return frame.Length; - } -} From 3e6a692c6e91096e94059a37c7ede81c751212e8 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:16:57 +0700 Subject: [PATCH 54/61] Remove migrated SV publisher validation from embedded engine --- .../SampledValuesPublisherValidation.cs | 100 ------------------ 1 file changed, 100 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs deleted file mode 100644 index c6a4356..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherValidation.cs +++ /dev/null @@ -1,100 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public enum SampledValuesPublisherValidationSeverity -{ - Info, - Warning, - Error -} - -public sealed record SampledValuesPublisherValidationFinding( - SampledValuesPublisherValidationSeverity Severity, - string Code, - string Message, - string Detail = ""); - -public sealed class SampledValuesPublisherValidationReport -{ - public SampledValuesPublisherValidationReport(IReadOnlyList findings) - { - Findings = findings; - } - - public IReadOnlyList Findings { get; } - public bool HasErrors => Findings.Any(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); - public int ErrorCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Error); - public int WarningCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Warning); - public int InfoCount => Findings.Count(f => f.Severity == SampledValuesPublisherValidationSeverity.Info); -} - -public static class SampledValuesPublisherValidator -{ - public static SampledValuesPublisherValidationReport Validate(SclSampledValuesStream stream) - { - ArgumentNullException.ThrowIfNull(stream); - var findings = new List(); - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_STREAM", stream.ControlBlockReference, $"svID={Text(stream.SvId)}, datSet={Text(stream.DataSetReference)}"); - - if (!stream.Address.AppId.HasValue) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_APPID_MISSING", "APPID is missing.", "Add Communication/SubNetwork/ConnectedAP/SMV/Address/P type=APPID."); - else if (stream.Address.AppId.Value == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_APPID_ZERO", "APPID is 0x0000.", "Use the APPID expected by the subscriber unless this is intentional."); - - if (!stream.Address.DestinationMac.HasValue) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_MISSING", "Destination MAC is missing or invalid.", stream.Address.DestinationMacText); - else - ValidateDestinationMac(stream.Address.DestinationMac.Value, findings); - - if (string.IsNullOrWhiteSpace(stream.SvId)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_ID_MISSING", "svID/smvID is missing."); - - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DATASET_MISSING", "DataSet cannot be resolved.", stream.DataSetName); - - if (stream.ConfigurationRevision == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_CONFREV_ZERO", "confRev is 0.", "Most engineering files use a positive configuration revision."); - - if (stream.SampleRate == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_SMPRATE_MISSING", "smpRate is missing or 0.", "The operator must select an explicit sample rate in ARSVIN."); - - var noAsdu = (stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu); - if (noAsdu > SampledValuesPublisherProfile.MaxAsduPerFrame) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_NOFASDU_UNSUPPORTED", $"nofASDU={noAsdu} is above the supported limit.", $"This publisher supports 1..{SampledValuesPublisherProfile.MaxAsduPerFrame} ASDU(s) per frame."); - else if (noAsdu > 1) - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_NOFASDU_PACKING", $"nofASDU={noAsdu} ASDU(s) per Ethernet frame.", "The publisher will pack sequential samples into one SavPdu."); - - var layout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); - if (!layout.IsFullySupported) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_PAYLOAD_UNSUPPORTED", "Unsupported SV payload layout.", string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}"))); - else - Add(findings, SampledValuesPublisherValidationSeverity.Info, "SV_PAYLOAD_LAYOUT", "Payload layout supported.", $"entries={stream.Entries.Count}, payload={layout.PayloadByteLength} bytes per ASDU."); - - return new SampledValuesPublisherValidationReport(findings); - } - - private static void ValidateDestinationMac(MacAddress mac, List findings) - { - var bytes = mac.ToArray(); - if (bytes.All(value => value == 0)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_ZERO", "Destination MAC is all zeros.", mac.ToString()); - else if (bytes.All(value => value == 0xFF)) - Add(findings, SampledValuesPublisherValidationSeverity.Error, "SV_DEST_MAC_BROADCAST", "Destination MAC should not be broadcast for SV.", mac.ToString()); - else if ((bytes[0] & 0x01) == 0) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_UNICAST", "Destination MAC is not multicast.", mac.ToString()); - else if (!(bytes[0] == 0x01 && bytes[1] == 0x0C && bytes[2] == 0xCD && bytes[3] == 0x04)) - Add(findings, SampledValuesPublisherValidationSeverity.Warning, "SV_DEST_MAC_RANGE", "Destination MAC is multicast but outside the common SV multicast range.", mac.ToString()); - } - - private static void Add( - List findings, - SampledValuesPublisherValidationSeverity severity, - string code, - string message, - string detail = "") - => findings.Add(new SampledValuesPublisherValidationFinding(severity, code, message, detail)); - - private static string Text(string value) => string.IsNullOrWhiteSpace(value) ? "-" : value; -} From f59ed336005e1d9e58ab8d118c4e840e2925f4f3 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:17:20 +0700 Subject: [PATCH 55/61] Remove migrated SV publisher profile from embedded engine --- .../SampledValuesPublisherProfile.cs | 232 ------------------ 1 file changed, 232 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs deleted file mode 100644 index 964e29e..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPublisherProfile.cs +++ /dev/null @@ -1,232 +0,0 @@ -using AR.Iec61850.Ethernet; -using AR.Iec61850.Mms; -using AR.Iec61850.Scl; - -namespace AR.Iec61850.SampledValues; - -public sealed class SampledValuesPublisherProfile -{ - public const ushort MaxAsduPerFrame = 8; - private SampledValuesPublisherProfile( - SclSampledValuesStream stream, - ushort appId, - MacAddress destination, - VlanTag? vlan) - { - Stream = stream; - AppId = appId; - Destination = destination; - Vlan = vlan; - PayloadLayout = SampledValuesPayloadLayout.FromDataSet(stream.Entries); - } - - public SclSampledValuesStream Stream { get; } - public ushort AppId { get; } - public MacAddress Destination { get; } - public VlanTag? Vlan { get; } - public SampledValuesPayloadLayout PayloadLayout { get; } - public IReadOnlyList Entries => Stream.Entries; - public ushort AsduPerFrame => ResolveAsduPerFrame(Stream); - - public static IReadOnlyList CreateMany(SclDocument document) - { - ArgumentNullException.ThrowIfNull(document); - return document.SampledValuesStreams.Select(Create).ToArray(); - } - - public static SampledValuesPublisherProfile FromScl(SclDocument document, string? controlBlockReference = null) - { - ArgumentNullException.ThrowIfNull(document); - - var stream = string.IsNullOrWhiteSpace(controlBlockReference) - ? document.SampledValuesStreams.FirstOrDefault() - : document.SampledValuesStreams.FirstOrDefault(s => string.Equals(s.ControlBlockReference, controlBlockReference, StringComparison.OrdinalIgnoreCase)); - - if (stream is null) - throw new SclProfileException("No matching SampledValueControl stream was found in the SCL document."); - - return Create(stream); - } - - public SampledValuesFrame CreateFrame( - MacAddress source, - ushort sampleCount, - ReadOnlySpan samplePayload, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - if (AsduPerFrame != 1) - throw new InvalidOperationException($"SV {Stream.ControlBlockReference} declares nofASDU={AsduPerFrame}. Use the multi-ASDU CreateFrame overload."); - - return CreateFrame( - source, - sampleCount, - new[] { samplePayload.ToArray() }, - referenceTime, - sampleSynchronization, - sampleCounterWrap); - } - - public SampledValuesFrame CreateFrame( - MacAddress source, - ushort sampleCount, - IReadOnlyList samplePayloads, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - ArgumentNullException.ThrowIfNull(samplePayloads); - ValidateAsduPayloadBatch(samplePayloads); - - if (sampleCounterWrap is 1) - throw new ArgumentOutOfRangeException(nameof(sampleCounterWrap), "SV sample counter wrap must be greater than 1 when supplied."); - - var asdus = new List(samplePayloads.Count); - for (var i = 0; i < samplePayloads.Count; i++) - { - asdus.Add(new SampledValueAsdu - { - SvId = Stream.SvId, - DataSetReference = Stream.DataSetReference, - SampleCount = SampleCounterPolicy.Increment(sampleCount, sampleCounterWrap, i), - ConfigurationRevision = Stream.ConfigurationRevision, - ReferenceTime = referenceTime, - SampleSynchronization = sampleSynchronization, - SampleRate = Stream.SampleRate == 0 ? null : Stream.SampleRate, - SampleMode = TryMapSampleMode(Stream.SampleMode), - SamplePayload = samplePayloads[i].ToArray() - }); - } - - return new SampledValuesFrame - { - Destination = Destination, - Source = source, - Vlan = Vlan, - AppId = AppId, - Pdu = new SampledValuesPdu { Asdus = asdus } - }; - } - - public byte[] BuildEthernetFrame( - MacAddress source, - ushort sampleCount, - ReadOnlySpan samplePayload, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - return SampledValuesFrameBuilder.BuildEthernetFrame( - CreateFrame(source, sampleCount, samplePayload, referenceTime, sampleSynchronization, sampleCounterWrap)); - } - - public byte[] BuildEthernetFrame( - MacAddress source, - ushort sampleCount, - IReadOnlyList samplePayloads, - Iec61850UtcTime? referenceTime = null, - byte sampleSynchronization = 2, - ushort? sampleCounterWrap = null) - { - return SampledValuesFrameBuilder.BuildEthernetFrame( - CreateFrame(source, sampleCount, samplePayloads, referenceTime, sampleSynchronization, sampleCounterWrap)); - } - - public byte[] BuildPayload(IReadOnlyList values) - => SampledValuesPayloadBuilder.BuildPayload(PayloadLayout, values); - - public byte[] BuildDefaultPayload(Iec61850UtcTime? timestamp = null) - => SampledValuesPayloadBuilder.BuildDefaultPayload(PayloadLayout, timestamp); - - public byte[] BuildDemoPayload( - long sampleIndex, - double sampleRateHz, - double nominalHz, - Iec61850UtcTime? timestamp = null, - SampledValueQuality? quality = null) - => SampledValuesPayloadBuilder.BuildDemoPayload(PayloadLayout, sampleIndex, sampleRateHz, nominalHz, timestamp, quality); - - public ushort? ResolveSampleCounterWrap(double nominalFrequencyHz) - { - var mode = TryMapSampleMode(Stream.SampleMode); - if (Stream.SampleRate == 0) - return null; - - var samplesPerSecond = mode switch - { - 0 => Stream.SampleRate * nominalFrequencyHz, - 1 => Stream.SampleRate, - _ => 0 - }; - - if (samplesPerSecond <= 0 || samplesPerSecond > ushort.MaxValue) - return null; - - return (ushort)Math.Round(samplesPerSecond); - } - - public static SampledValuesPublisherProfile Create(SclSampledValuesStream stream) - { - if (!stream.Address.AppId.HasValue) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid APPID in SCL Communication/SMV."); - - if (!stream.Address.DestinationMac.HasValue) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no valid destination MAC in SCL Communication/SMV."); - - if (string.IsNullOrWhiteSpace(stream.SvId)) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no svID/smvID."); - - if (string.IsNullOrWhiteSpace(stream.DataSetReference) || stream.Entries.Count == 0) - throw new SclProfileException($"SV {stream.ControlBlockReference} has no resolved DataSet entries."); - - var noAsdu = ResolveAsduPerFrame(stream); - if (noAsdu > MaxAsduPerFrame) - throw new SclProfileException($"SV {stream.ControlBlockReference} declares nofASDU={stream.NoAsdu}. This publisher supports up to {MaxAsduPerFrame} ASDUs per frame."); - - return new SampledValuesPublisherProfile(stream, stream.Address.AppId.Value, stream.Address.DestinationMac.Value, stream.Address.ToVlanTag()); - } - - public static ushort ResolveAsduPerFrame(SclSampledValuesStream stream) - { - ArgumentNullException.ThrowIfNull(stream); - return (stream.NoAsdu == 0 ? (ushort)1 : stream.NoAsdu); - } - - public static double ResolvePublicationRate(double sampleRateHz, ushort noAsdu) - { - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - if (noAsdu == 0) - throw new ArgumentOutOfRangeException(nameof(noAsdu), "nofASDU must be greater than 0."); - - return sampleRateHz / noAsdu; - } - - private void ValidateAsduPayloadBatch(IReadOnlyList samplePayloads) - { - var expected = AsduPerFrame; - if (samplePayloads.Count != expected) - throw new ArgumentException($"SV {Stream.ControlBlockReference} expects nofASDU={expected}, got {samplePayloads.Count} payload(s).", nameof(samplePayloads)); - - for (var i = 0; i < samplePayloads.Count; i++) - { - if (samplePayloads[i].Length != PayloadLayout.PayloadByteLength) - throw new ArgumentException($"SV ASDU payload {i} has {samplePayloads[i].Length} byte(s), expected {PayloadLayout.PayloadByteLength}.", nameof(samplePayloads)); - } - } - - private static ushort? TryMapSampleMode(string sampleMode) - { - if (string.IsNullOrWhiteSpace(sampleMode)) - return null; - - return sampleMode.Trim() switch - { - "SmpPerPeriod" => 0, - "SmpPerSec" => 1, - "SecPerSmp" => 2, - _ => null - }; - } -} From 227084460a85f95b57d49f87a3237e5d02867252 Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:17:36 +0700 Subject: [PATCH 56/61] Remove migrated SV payload builder from embedded engine --- .../SampledValuesPayloadBuilder.cs | 304 ------------------ 1 file changed, 304 deletions(-) delete mode 100644 src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs diff --git a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs b/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs deleted file mode 100644 index c7deaf9..0000000 --- a/src/ARSVIN.Engine/AR.Iec61850/SampledValues/SampledValuesPayloadBuilder.cs +++ /dev/null @@ -1,304 +0,0 @@ -using AR.Iec61850.Asn1; -using AR.Iec61850.Mms; -using System.Buffers.Binary; -using System.Globalization; - -namespace AR.Iec61850.SampledValues; - -public static class SampledValuesPayloadBuilder -{ - public static byte[] BuildPayload(SampledValuesPayloadLayout layout, IReadOnlyList values) - { - ArgumentNullException.ThrowIfNull(layout); - ArgumentNullException.ThrowIfNull(values); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - if (values.Count != layout.Elements.Count) - throw new ArgumentException($"SV payload value count mismatch. Expected {layout.Elements.Count}, got {values.Count}.", nameof(values)); - - var payload = new byte[layout.PayloadByteLength]; - for (var i = 0; i < layout.Elements.Count; i++) - { - var element = layout.Elements[i]; - WriteValue(payload.AsSpan(element.Offset, element.Width), element, values[i]); - } - - return payload; - } - - public static byte[] BuildDefaultPayload(SampledValuesPayloadLayout layout, Iec61850UtcTime? timestamp = null, SampledValueQuality? quality = null) - { - ArgumentNullException.ThrowIfNull(layout); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - var payload = new byte[layout.PayloadByteLength]; - foreach (var element in layout.Elements) - { - if (element.Kind == SampledValuePayloadElementKind.Timestamp && timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(payload.AsSpan(element.Offset, element.Width)); - - if (element.Kind == SampledValuePayloadElementKind.Quality) - (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(payload.AsSpan(element.Offset, element.Width)); - } - - return payload; - } - - public static byte[] BuildDemoPayload( - SampledValuesPayloadLayout layout, - long sampleIndex, - double sampleRateHz, - double nominalHz, - Iec61850UtcTime? timestamp = null, - SampledValueQuality? quality = null) - { - ArgumentNullException.ThrowIfNull(layout); - - if (!layout.IsFullySupported) - throw new InvalidOperationException(BuildUnsupportedLayoutMessage(layout)); - - if (sampleRateHz <= 0) - throw new ArgumentOutOfRangeException(nameof(sampleRateHz), "Sample rate must be greater than 0."); - - if (nominalHz <= 0) - throw new ArgumentOutOfRangeException(nameof(nominalHz), "Nominal frequency must be greater than 0."); - - var payload = new byte[layout.PayloadByteLength]; - foreach (var element in layout.Elements) - { - var destination = payload.AsSpan(element.Offset, element.Width); - if (element.Kind == SampledValuePayloadElementKind.Quality) - { - (quality ?? SampledValueQuality.Good).ToBytes(element.Width).CopyTo(destination); - continue; - } - - if (element.Kind == SampledValuePayloadElementKind.BitString || - element.Kind == SampledValuePayloadElementKind.EntryTime) - { - continue; - } - - if (element.Kind == SampledValuePayloadElementKind.Timestamp) - { - if (timestamp is { } time) - BerWriter.EncodeUtcTime(time.Value, time.Quality).CopyTo(destination); - continue; - } - - var value = ComputeDemoValue(element, sampleIndex, sampleRateHz, nominalHz); - WriteNumeric(destination, element.Kind, value); - } - - return payload; - } - - private static void WriteValue(Span destination, SampledValuePayloadElement element, MmsDataValue value) - { - switch (element.Kind) - { - case SampledValuePayloadElementKind.Boolean: - destination[0] = value.Kind == MmsDataKind.Boolean && value.Value is true ? (byte)1 : (byte)0; - return; - - case SampledValuePayloadElementKind.Int8: - case SampledValuePayloadElementKind.Int16: - case SampledValuePayloadElementKind.Int32: - case SampledValuePayloadElementKind.Int64: - case SampledValuePayloadElementKind.Enum: - WriteNumeric(destination, element.Kind, ToInt64(value)); - return; - - case SampledValuePayloadElementKind.UInt8: - case SampledValuePayloadElementKind.UInt16: - case SampledValuePayloadElementKind.UInt24: - case SampledValuePayloadElementKind.UInt32: - case SampledValuePayloadElementKind.UInt64: - WriteUnsigned(destination, element.Kind, ToUInt64(value)); - return; - - case SampledValuePayloadElementKind.Float32: - WriteFloat32(destination, ToSingle(value)); - return; - - case SampledValuePayloadElementKind.Float64: - WriteFloat64(destination, ToDouble(value)); - return; - - case SampledValuePayloadElementKind.Quality: - case SampledValuePayloadElementKind.BitString: - case SampledValuePayloadElementKind.OctetString: - case SampledValuePayloadElementKind.VisibleString: - case SampledValuePayloadElementKind.EntryTime: - CopyRawPayloadBytes(destination, value); - return; - - case SampledValuePayloadElementKind.Timestamp: - if (value.Kind != MmsDataKind.UtcTime || value.Value is not Iec61850UtcTime utc) - throw new ArgumentException($"SV element {element.SignalReference} expects UTC time."); - BerWriter.EncodeUtcTime(utc.Value, utc.Quality).CopyTo(destination); - return; - - default: - throw new NotSupportedException($"SV payload element kind {element.Kind} is not supported."); - } - } - - private static void WriteNumeric(Span destination, SampledValuePayloadElementKind kind, long value) - { - switch (kind) - { - case SampledValuePayloadElementKind.Int8: - destination[0] = unchecked((byte)(sbyte)value); - break; - case SampledValuePayloadElementKind.Int16: - BinaryPrimitives.WriteInt16BigEndian(destination, checked((short)value)); - break; - case SampledValuePayloadElementKind.Int32: - case SampledValuePayloadElementKind.Enum: - BinaryPrimitives.WriteInt32BigEndian(destination, checked((int)value)); - break; - case SampledValuePayloadElementKind.Int64: - BinaryPrimitives.WriteInt64BigEndian(destination, value); - break; - case SampledValuePayloadElementKind.UInt8: - case SampledValuePayloadElementKind.UInt16: - case SampledValuePayloadElementKind.UInt24: - case SampledValuePayloadElementKind.UInt32: - case SampledValuePayloadElementKind.UInt64: - WriteUnsigned(destination, kind, checked((ulong)value)); - break; - case SampledValuePayloadElementKind.Float32: - WriteFloat32(destination, value); - break; - case SampledValuePayloadElementKind.Float64: - WriteFloat64(destination, value); - break; - default: - throw new NotSupportedException($"SV numeric kind {kind} is not supported."); - } - } - - private static void WriteUnsigned(Span destination, SampledValuePayloadElementKind kind, ulong value) - { - switch (kind) - { - case SampledValuePayloadElementKind.UInt8: - destination[0] = checked((byte)value); - break; - case SampledValuePayloadElementKind.UInt16: - BinaryPrimitives.WriteUInt16BigEndian(destination, checked((ushort)value)); - break; - case SampledValuePayloadElementKind.UInt24: - if (value > 0xFFFFFF) - throw new OverflowException("UINT24 value is out of range."); - destination[0] = (byte)((value >> 16) & 0xFF); - destination[1] = (byte)((value >> 8) & 0xFF); - destination[2] = (byte)(value & 0xFF); - break; - case SampledValuePayloadElementKind.UInt32: - BinaryPrimitives.WriteUInt32BigEndian(destination, checked((uint)value)); - break; - case SampledValuePayloadElementKind.UInt64: - BinaryPrimitives.WriteUInt64BigEndian(destination, value); - break; - default: - throw new NotSupportedException($"SV unsigned kind {kind} is not supported."); - } - } - - private static void WriteFloat32(Span destination, double value) - => BinaryPrimitives.WriteUInt32BigEndian(destination, unchecked((uint)BitConverter.SingleToInt32Bits((float)value))); - - private static void WriteFloat64(Span destination, double value) - => BinaryPrimitives.WriteUInt64BigEndian(destination, unchecked((ulong)BitConverter.DoubleToInt64Bits(value))); - - private static void CopyRawPayloadBytes(Span destination, MmsDataValue value) - { - var raw = value.RawValue.ToArray(); - if (value.Kind == MmsDataKind.BitString && raw.Length == destination.Length + 1) - raw = raw[1..]; - - if (raw.Length > destination.Length) - throw new ArgumentException($"SV raw value has {raw.Length} bytes but the payload slot has {destination.Length} bytes."); - - raw.CopyTo(destination); - } - - private static long ToInt64(MmsDataValue value) - => value.Kind switch - { - MmsDataKind.Integer when value.Value is long signed => signed, - MmsDataKind.Unsigned when value.Value is ulong unsigned => checked((long)unsigned), - MmsDataKind.Boolean when value.Value is bool boolean => boolean ? 1 : 0, - _ => Convert.ToInt64(value.Value, CultureInfo.InvariantCulture) - }; - - private static ulong ToUInt64(MmsDataValue value) - => value.Kind switch - { - MmsDataKind.Unsigned when value.Value is ulong unsigned => unsigned, - MmsDataKind.Integer when value.Value is long signed => checked((ulong)signed), - MmsDataKind.Boolean when value.Value is bool boolean => boolean ? 1UL : 0UL, - _ => Convert.ToUInt64(value.Value, CultureInfo.InvariantCulture) - }; - - private static float ToSingle(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f - : Convert.ToSingle(value.Value, CultureInfo.InvariantCulture); - - private static double ToDouble(MmsDataValue value) - => value.Kind == MmsDataKind.FloatingPoint && value.Value is float f - ? f - : Convert.ToDouble(value.Value, CultureInfo.InvariantCulture); - - private static long ComputeDemoValue(SampledValuePayloadElement element, long sampleIndex, double sampleRateHz, double nominalHz) - { - var amplitude = ResolveDemoAmplitude(element); - var angle = (2.0 * Math.PI * nominalHz * sampleIndex / sampleRateHz) + ResolvePhaseRadians(element.SignalReference); - return (long)Math.Round(amplitude * Math.Sin(angle)); - } - - private static int ResolveDemoAmplitude(SampledValuePayloadElement element) - { - var reference = element.SignalReference; - if (reference.Contains("/TVTR", StringComparison.OrdinalIgnoreCase) || - reference.Contains(".Vol", StringComparison.OrdinalIgnoreCase)) - return 100_000; - - if (reference.Contains("/TCTR", StringComparison.OrdinalIgnoreCase) || - reference.Contains(".Amp", StringComparison.OrdinalIgnoreCase)) - return 10_000; - - return 1_000; - } - - private static double ResolvePhaseRadians(string signalReference) - { - var slash = signalReference.LastIndexOf('/'); - var dot = signalReference.IndexOf('.', slash + 1); - if (slash < 0 || dot <= slash) - return 0; - - var logicalNode = signalReference[(slash + 1)..dot]; - var digits = new string(logicalNode.Reverse().TakeWhile(char.IsDigit).Reverse().ToArray()); - if (!int.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var instance)) - return 0; - - return instance switch - { - 2 => -2.0 * Math.PI / 3.0, - 3 => 2.0 * Math.PI / 3.0, - _ => 0 - }; - } - - private static string BuildUnsupportedLayoutMessage(SampledValuesPayloadLayout layout) - => "SV payload layout has unsupported DataSet entries: " + - string.Join("; ", layout.UnsupportedElements.Select(x => $"{x.SignalReference} bType={x.BType}")); -} From f1a0d1ec88ef1e7f4ecb60dfec5a46637c584f6d Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:34:00 +0700 Subject: [PATCH 57/61] Record migrated Subscriber observation and evidence contracts --- docs/engine-integration.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/engine-integration.md b/docs/engine-integration.md index bb022e9..48f2522 100644 --- a/docs/engine-integration.md +++ b/docs/engine-integration.md @@ -16,7 +16,10 @@ The paired engine branch now owns the reusable contracts required by both applic - semantic quality decoding and publisher quality encoding; - explicit CT/VT and measurement-domain context; - Sampled Values publisher evidence report contracts; -- local transmitter timing-health evidence. +- local transmitter timing-health evidence; +- unified live/PCAP observation windows and stable logical stream keys; +- vendor-neutral profile evidence, confidence, and SCL-versus-wire comparison; +- Subscriber JSON/Markdown evidence and regression-comparison contracts. These contracts are implemented and tested in ARIEC61850. Application code consumes them; it must not maintain a second behavioral copy. @@ -67,8 +70,9 @@ dotnet build .\src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj ` 3. Run Publisher dry-run and PCAP generation. 4. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. 5. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. -6. Run authorized isolated live capture/transmit tests only after offline tests pass. -7. Record both Git commit SHAs in every test note. +6. Export two Subscriber evidence reports and verify the shared comparator identifies source failover, health regression, and continuity changes. +7. Run authorized isolated live capture/transmit tests only after offline tests pass. +8. Record both Git commit SHAs in every test note. Commands: From 2f131eecc02bcb2fe6c196ac8737d19bc73e4bef Mon Sep 17 00:00:00 2001 From: masarray Date: Thu, 23 Jul 2026 10:41:09 +0700 Subject: [PATCH 58/61] Record migrated multi-ASDU session behavior --- docs/engine-integration.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/engine-integration.md b/docs/engine-integration.md index 48f2522..1b3a43c 100644 --- a/docs/engine-integration.md +++ b/docs/engine-integration.md @@ -17,6 +17,7 @@ The paired engine branch now owns the reusable contracts required by both applic - explicit CT/VT and measurement-domain context; - Sampled Values publisher evidence report contracts; - local transmitter timing-health evidence; +- multi-ASDU Publisher profile/session behavior with bounded counter wrap; - unified live/PCAP observation windows and stable logical stream keys; - vendor-neutral profile evidence, confidence, and SCL-versus-wire comparison; - Subscriber JSON/Markdown evidence and regression-comparison contracts. @@ -67,7 +68,7 @@ dotnet build .\src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj ` 1. Build and test `ARIEC61850` first. 2. Build ARSVIN Publisher and ArSubsv against that exact checkout. -3. Run Publisher dry-run and PCAP generation. +3. Run Publisher dry-run and PCAP generation, including `nofASDU=1` and one multi-ASDU case. 4. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. 5. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. 6. Export two Subscriber evidence reports and verify the shared comparator identifies source failover, health regression, and continuity changes. From be7263a87ad287c928ba8881f2e34f862d10f999 Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 24 Jul 2026 07:46:19 +0700 Subject: [PATCH 59/61] Pin and validate the paired ARIEC61850 engine revision --- .github/workflows/ci.yml | 36 ++++++++++------ .github/workflows/codeql.yml | 32 ++++++++++---- build.ps1 | 69 ++++++++++++++++++++++-------- docs/engine-integration.md | 66 +++++++++++++++-------------- engines/ARIEC61850.lock.json | 9 ++++ scripts/resolve-engine-pin.ps1 | 54 ++++++++++++++++++++++++ scripts/test-paired-engine.ps1 | 77 ++++++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+), 71 deletions(-) create mode 100644 engines/ARIEC61850.lock.json create mode 100644 scripts/resolve-engine-pin.ps1 create mode 100644 scripts/test-paired-engine.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd7a785..63dc7ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,6 @@ on: permissions: contents: read -env: - ARIEC61850_REF: agent/sv-core-unification - jobs: public-content: name: Validate licensing, provenance, wording, and engine ownership @@ -50,7 +47,7 @@ jobs: run: exit 1 build-test: - name: Build, test, and validate site + name: Build, test, and validate pinned engine integration needs: public-content runs-on: windows-2025 defaults: @@ -64,19 +61,33 @@ jobs: with: path: arsvin - - name: Checkout sibling ARIEC61850 + - name: Resolve pinned ARIEC61850 revision + id: engine_pin + shell: pwsh + run: | + $pin = .\scripts\resolve-engine-pin.ps1 -AsObject + "repository=$($pin.Repository)" | Add-Content $env:GITHUB_OUTPUT + "ref=$($pin.Ref)" | Add-Content $env:GITHUB_OUTPUT + "commit=$($pin.Commit)" | Add-Content $env:GITHUB_OUTPUT + + - name: Checkout pinned sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - repository: masarray/ARIEC61850 - ref: ${{ env.ARIEC61850_REF }} + repository: ${{ steps.engine_pin.outputs.repository }} + ref: ${{ steps.engine_pin.outputs.commit }} path: ARIEC61850 - - name: Record paired engine revision + - name: Record and verify paired engine revision shell: pwsh run: | - $engineSha = git -C "$env:ARIEC61850_ROOT" rev-parse HEAD - "ARIEC61850_REF=$env:ARIEC61850_REF" | Out-File artifacts-engine-revision.txt -Encoding utf8 - "ARIEC61850_SHA=$engineSha" | Add-Content artifacts-engine-revision.txt + $engineSha = (git -C "$env:ARIEC61850_ROOT" rev-parse HEAD).Trim().ToLowerInvariant() + $expected = '${{ steps.engine_pin.outputs.commit }}'.ToLowerInvariant() + if ($engineSha -ne $expected) { + throw "Pinned engine mismatch. Expected $expected, found $engineSha." + } + "ARIEC61850_REF=${{ steps.engine_pin.outputs.ref }}" | Out-File artifacts-engine-revision.txt -Encoding utf8 + "ARIEC61850_EXPECTED_SHA=$expected" | Add-Content artifacts-engine-revision.txt + "ARIEC61850_ACTUAL_SHA=$engineSha" | Add-Content artifacts-engine-revision.txt Get-Content artifacts-engine-revision.txt - name: Setup .NET @@ -115,6 +126,7 @@ jobs: with: name: arsvin-dependency-evidence path: | + arsvin/engines/ARIEC61850.lock.json arsvin/src/**/packages.lock.json arsvin/tests/**/packages.lock.json arsvin/artifacts-engine-revision.txt @@ -144,7 +156,7 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Test against sibling ARIEC61850 with coverage gates + - name: Test against pinned ARIEC61850 with coverage gates shell: pwsh run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bc9652..5c2749b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,12 +12,9 @@ permissions: security-events: write contents: read -env: - ARIEC61850_REF: agent/sv-core-unification - jobs: analyze: - name: Analyze C# solution + name: Analyze pinned paired C# solution runs-on: windows-2025 defaults: run: @@ -30,13 +27,30 @@ jobs: with: path: arsvin - - name: Checkout sibling ARIEC61850 + - name: Resolve pinned ARIEC61850 revision + id: engine_pin + shell: pwsh + run: | + $pin = .\scripts\resolve-engine-pin.ps1 -AsObject + "repository=$($pin.Repository)" | Add-Content $env:GITHUB_OUTPUT + "commit=$($pin.Commit)" | Add-Content $env:GITHUB_OUTPUT + + - name: Checkout pinned sibling ARIEC61850 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - repository: masarray/ARIEC61850 - ref: ${{ env.ARIEC61850_REF }} + repository: ${{ steps.engine_pin.outputs.repository }} + ref: ${{ steps.engine_pin.outputs.commit }} path: ARIEC61850 + - name: Verify pinned engine commit + shell: pwsh + run: | + $actual = (git -C "$env:ARIEC61850_ROOT" rev-parse HEAD).Trim().ToLowerInvariant() + $expected = '${{ steps.engine_pin.outputs.commit }}'.ToLowerInvariant() + if ($actual -ne $expected) { + throw "Pinned engine mismatch. Expected $expected, found $actual." + } + - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: @@ -53,10 +67,10 @@ jobs: languages: csharp build-mode: manual - - name: Restore paired solution dependency graph + - name: Restore pinned paired solution dependency graph run: dotnet restore ARSVIN.sln - - name: Build paired solution with warnings as errors + - name: Build pinned paired solution with warnings as errors run: dotnet build ARSVIN.sln -c Release --no-restore -warnaserror - name: Perform CodeQL Analysis diff --git a/build.ps1 b/build.ps1 index a417478..e5d1cb6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,6 +1,8 @@ [CmdletBinding()] param( - [string] $EngineRef = $(if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { 'agent/sv-core-unification' }) + [string] $EngineCommit = $(if ($env:ARIEC61850_COMMIT) { $env:ARIEC61850_COMMIT } else { '' }), + [string] $EngineRef = $(if ($env:ARIEC61850_REF) { $env:ARIEC61850_REF } else { '' }), + [switch] $AllowEngineDrift ) $ErrorActionPreference = 'Stop' @@ -11,6 +13,14 @@ $solution = Join-Path $root 'ARSVIN.sln' $appProject = Join-Path $root 'src\ARSVIN\ARSVIN.csproj' $subscriberProject = Join-Path $root 'src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj' $testProject = Join-Path $root 'tests\ARSVIN.Tests\ARSVIN.Tests.csproj' +$pin = & (Join-Path $root 'scripts\resolve-engine-pin.ps1') -RepositoryRoot $root -AsObject +$resolvedEngineCommit = if ([string]::IsNullOrWhiteSpace($EngineCommit)) { $pin.Commit } else { $EngineCommit.Trim().ToLowerInvariant() } +$resolvedEngineRef = if ([string]::IsNullOrWhiteSpace($EngineRef)) { $pin.Ref } else { $EngineRef.Trim() } + +if ($resolvedEngineCommit -notmatch '^[0-9a-f]{40}$') { + throw "ARIEC61850 commit '$resolvedEngineCommit' is not a full 40-character SHA." +} + $engineRoot = if ($env:ARIEC61850_ROOT) { [System.IO.Path]::GetFullPath($env:ARIEC61850_ROOT) } @@ -38,37 +48,60 @@ function Invoke-DotNet { if (-not (Test-Path $engineProject -PathType Leaf)) { if ($env:CI -ne 'true') { - throw "ARIEC61850 sibling repository was not found at $engineRoot. Clone https://github.com/masarray/ARIEC61850 beside this repository or set ARIEC61850_ROOT." + throw "ARIEC61850 sibling repository was not found at $engineRoot. Clone https://github.com/$($pin.Repository) beside this repository, checkout $resolvedEngineCommit, or set ARIEC61850_ROOT." } - Write-Host "==> CI bootstrap: cloning ARIEC61850 ref $EngineRef into $engineRoot" + Write-Host "==> CI bootstrap: fetching pinned ARIEC61850 commit $resolvedEngineCommit into $engineRoot" $engineParent = Split-Path -Parent $engineRoot New-Item -ItemType Directory -Path $engineParent -Force | Out-Null if (Test-Path $engineRoot) { Remove-Item $engineRoot -Recurse -Force } - Invoke-Checked -FilePath 'git' -Arguments @( - 'clone', '--depth', '1', '--branch', $EngineRef, - 'https://github.com/masarray/ARIEC61850.git', $engineRoot - ) + New-Item -ItemType Directory -Path $engineRoot -Force | Out-Null + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'init') + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'remote', 'add', 'origin', $pin.RepositoryUrl) + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'fetch', '--depth', '1', 'origin', $resolvedEngineCommit) + Invoke-Checked -FilePath 'git' -Arguments @('-C', $engineRoot, 'checkout', '--detach', 'FETCH_HEAD') +} + +$engineShaOutput = & git -C $engineRoot rev-parse HEAD 2>&1 +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the paired ARIEC61850 commit.`n$($engineShaOutput -join [Environment]::NewLine)" +} +$engineSha = ($engineShaOutput -join '').Trim().ToLowerInvariant() + +if (-not $AllowEngineDrift -and $engineSha -ne $resolvedEngineCommit) { + throw "ARIEC61850 checkout mismatch. Expected $resolvedEngineCommit but found $engineSha. Run: git -C `"$engineRoot`" fetch origin $resolvedEngineCommit; git -C `"$engineRoot`" checkout --detach $resolvedEngineCommit" } $env:ARIEC61850_ROOT = $engineRoot -$env:ARIEC61850_REF = $EngineRef +$env:ARIEC61850_REF = $resolvedEngineRef +$env:ARIEC61850_COMMIT = $engineSha if ($env:GITHUB_ENV) { "ARIEC61850_ROOT=$engineRoot" | Add-Content $env:GITHUB_ENV - "ARIEC61850_REF=$EngineRef" | Add-Content $env:GITHUB_ENV + "ARIEC61850_REF=$resolvedEngineRef" | Add-Content $env:GITHUB_ENV + "ARIEC61850_COMMIT=$engineSha" | Add-Content $env:GITHUB_ENV } -$engineShaOutput = & git -C $engineRoot rev-parse HEAD 2>&1 -if ($LASTEXITCODE -ne 0) { - throw "Could not resolve the paired ARIEC61850 commit.`n$($engineShaOutput -join [Environment]::NewLine)" +$artifactRoot = Join-Path $root 'artifacts' +New-Item -ItemType Directory -Path $artifactRoot -Force | Out-Null +$revisionEvidence = [ordered]@{ + schemaVersion = 1 + repository = $pin.Repository + configuredRef = $resolvedEngineRef + expectedCommit = $resolvedEngineCommit + actualCommit = $engineSha + exactMatch = ($engineSha -eq $resolvedEngineCommit) + engineRoot = $engineRoot + applicationCommit = (& git -C $root rev-parse HEAD 2>$null | Select-Object -First 1) + generatedAt = [DateTimeOffset]::UtcNow.ToString('O') } -$engineSha = ($engineShaOutput -join '').Trim() +$revisionEvidence | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $artifactRoot 'engine-revision.json') -Encoding utf8 Write-Host "==> ARIEC61850 root: $engineRoot" -Write-Host "==> ARIEC61850 ref: $EngineRef" -Write-Host "==> ARIEC61850 commit: $engineSha" +Write-Host "==> ARIEC61850 ref: $resolvedEngineRef" +Write-Host "==> ARIEC61850 expected commit: $resolvedEngineCommit" +Write-Host "==> ARIEC61850 actual commit: $engineSha" Write-Host '==> Verifying current license, provenance, and public wording' & python (Join-Path $root 'scripts\verify-current-license.py') @@ -88,7 +121,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Engine ownership validation failed.' } -Write-Host '==> Restoring application and sibling-engine dependency graph' +Write-Host '==> Restoring application and pinned sibling-engine dependency graph' Invoke-DotNet -Arguments @('restore', $solution) Write-Host '==> Building ARSVIN Publisher' @@ -97,7 +130,7 @@ Invoke-DotNet -Arguments @('build', $appProject, '-c', 'Release', '--no-restore' Write-Host '==> Building ArSubsv Subscriber' Invoke-DotNet -Arguments @('build', $subscriberProject, '-c', 'Release', '--no-restore', '-warnaserror') -Write-Host '==> Running integration regression tests against ARIEC61850' +Write-Host '==> Running integration regression tests against pinned ARIEC61850' Invoke-DotNet -Arguments @('test', $testProject, '-c', 'Release', '--no-restore', '/p:TreatWarningsAsErrors=true') -Write-Host '==> Build completed successfully' +Write-Host '==> Paired build completed successfully' diff --git a/docs/engine-integration.md b/docs/engine-integration.md index 1b3a43c..2912a50 100644 --- a/docs/engine-integration.md +++ b/docs/engine-integration.md @@ -6,6 +6,18 @@ ARSVIN Publisher and ArSubsv Subscriber are derived applications. Reusable IEC 6 The application repository owns WPF presentation, commands, workflow orchestration, application settings, branding, and packaging. The inactive `src/ARSVIN.Engine` directory is temporary migration material and is not an active project reference. +## Pinned engine contract + +The paired revision is recorded in `engines/ARIEC61850.lock.json`. CI, CodeQL, local paired validation, packaging, and release automation must resolve the engine from that file and verify the exact 40-character commit SHA. A moving branch name is retained only as human context; it is not the reproducible build identity. + +Current paired revision for this draft: + +```text +ARIEC61850 ref: agent/sv-core-unification +ARIEC61850 commit: 143e6ca69986cd553405eec883a9928cdfda9367 +ARIEC61850 PR: #45 +``` + ## Migrated reusable contracts The paired engine branch now owns the reusable contracts required by both applications: @@ -36,56 +48,45 @@ For the unmerged integration test: ```powershell git clone https://github.com/masarray/ARIEC61850.git C:\Git\ARIEC61850 -git -C C:\Git\ARIEC61850 switch agent/sv-core-unification +git -C C:\Git\ARIEC61850 fetch origin 143e6ca69986cd553405eec883a9928cdfda9367 +git -C C:\Git\ARIEC61850 checkout --detach 143e6ca69986cd553405eec883a9928cdfda9367 git clone https://github.com/masarray/arsvin.git C:\Git\arsvin git -C C:\Git\arsvin switch agent/ariec-sibling-integration ``` -Then build the paired graph: +Run the complete paired gate: ```powershell cd C:\Git\arsvin -.\build.ps1 +.\scripts\test-paired-engine.ps1 ``` -The default sibling path can be overridden: +For a faster application-only iteration after the engine suite has already passed: ```powershell -$env:ARIEC61850_ROOT = 'D:\Engineering\ARIEC61850' .\build.ps1 ``` -or for a direct MSBuild invocation: +The default sibling path can be overridden: ```powershell -dotnet build .\src\ARSVIN.Subscriber\ARSVIN.Subscriber.csproj ` - -c Release ` - -p:ARIEC61850_ROOT='D:\Engineering\ARIEC61850' +$env:ARIEC61850_ROOT = 'D:\Engineering\ARIEC61850' +.\scripts\test-paired-engine.ps1 ``` ## Paired test sequence -1. Build and test `ARIEC61850` first. -2. Build ARSVIN Publisher and ArSubsv against that exact checkout. -3. Run Publisher dry-run and PCAP generation, including `nofASDU=1` and one multi-ASDU case. -4. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. -5. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. -6. Export two Subscriber evidence reports and verify the shared comparator identifies source failover, health regression, and continuity changes. -7. Run authorized isolated live capture/transmit tests only after offline tests pass. -8. Record both Git commit SHAs in every test note. - -Commands: - -```powershell -cd C:\Git\ARIEC61850 -dotnet restore .\ARIEC61850.sln -dotnet build .\ARIEC61850.sln -c Release -dotnet test .\ARIEC61850.sln -c Release --no-build - -cd C:\Git\arsvin -.\build.ps1 -``` +1. Verify the local engine checkout exactly matches the lock file. +2. Restore, build, and test `ARIEC61850`. +3. Build ARSVIN Publisher and ArSubsv against that exact checkout. +4. Run ARSVIN regression and coverage gates. +5. Run Publisher dry-run and PCAP generation, including `nofASDU=1` and one multi-ASDU case. +6. Open the generated PCAP in ArSubsv without SCL; verify generic raw words and unresolved semantics. +7. Import the matching SCL; verify ordered dataset mapping, evidence-backed scaling, waveform, RMS, and phasor behavior. +8. Export two Subscriber evidence reports and verify the shared comparator identifies source failover, health regression, and continuity changes. +9. Run authorized isolated live capture/transmit tests only after offline tests pass. +10. Preserve `artifacts/paired-validation/paired-validation.json` with the test notes. ## Migration rules @@ -93,8 +94,9 @@ cd C:\Git\arsvin - Application projects must not reference `ARSVIN.Engine`. - Manufacturer identity must not select an SV parser, dataset order, scaling, quality interpretation, timebase, or health result. - Engine changes require deterministic engine tests in ARIEC61850 and paired application regression tests in ARSVIN. -- CI records the exact paired engine commit; a moving unrecorded engine revision is not a reproducible release input. -- Lock files remain temporarily unlocked in this draft migration. They must be regenerated after the reviewed ARIEC61850 commit is pinned. +- CI records and verifies the exact paired engine commit. +- Changing the engine requires a reviewed lock-file update in the application PR. +- Lock files remain temporarily unlocked in this draft migration. NuGet lock files must be regenerated after both paired revisions are accepted. ## Current draft pairing @@ -103,4 +105,4 @@ cd C:\Git\arsvin | ARIEC61850 | `agent/sv-core-unification` | `masarray/ARIEC61850#45` | | ARSVIN | `agent/ariec-sibling-integration` | `masarray/arsvin#51` | -Neither pull request is intended for merge until paired local testing, CI, CodeQL, packaging, and offline PCAP/SCL regression tests are complete. +Neither pull request is intended for merge until paired local testing, pinned CI, CodeQL, packaging, and offline PCAP/SCL regression tests are complete. diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json new file mode 100644 index 0000000..bce1a71 --- /dev/null +++ b/engines/ARIEC61850.lock.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "repository": "masarray/ARIEC61850", + "ref": "agent/sv-core-unification", + "commit": "143e6ca69986cd553405eec883a9928cdfda9367", + "pairedPullRequest": 45, + "updatedAt": "2026-07-24T00:00:00Z", + "purpose": "Pinned sibling engine revision for paired local, CI, CodeQL, packaging, and release validation." +} diff --git a/scripts/resolve-engine-pin.ps1 b/scripts/resolve-engine-pin.ps1 new file mode 100644 index 0000000..ff670bc --- /dev/null +++ b/scripts/resolve-engine-pin.ps1 @@ -0,0 +1,54 @@ +[CmdletBinding()] +param( + [string] $RepositoryRoot = $(Split-Path -Parent $PSScriptRoot), + [switch] $AsObject +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$lockPath = Join-Path $RepositoryRoot 'engines\ARIEC61850.lock.json' +if (-not (Test-Path $lockPath -PathType Leaf)) { + throw "Pinned ARIEC61850 revision file was not found: $lockPath" +} + +try { + $document = Get-Content $lockPath -Raw | ConvertFrom-Json +} +catch { + throw "Pinned ARIEC61850 revision file is invalid JSON: $($_.Exception.Message)" +} + +if ([int] $document.schemaVersion -ne 1) { + throw "Unsupported ARIEC61850 lock schema version '$($document.schemaVersion)'." +} + +$repository = ([string] $document.repository).Trim() +$ref = ([string] $document.ref).Trim() +$commit = ([string] $document.commit).Trim().ToLowerInvariant() + +if ($repository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw "Invalid ARIEC61850 repository '$repository'." +} +if ([string]::IsNullOrWhiteSpace($ref)) { + throw 'Pinned ARIEC61850 ref cannot be empty.' +} +if ($commit -notmatch '^[0-9a-f]{40}$') { + throw "Pinned ARIEC61850 commit '$commit' is not a full 40-character SHA." +} + +$result = [pscustomobject]@{ + SchemaVersion = 1 + Repository = $repository + RepositoryUrl = "https://github.com/$repository.git" + Ref = $ref + Commit = $commit + PairedPullRequest = [int] $document.pairedPullRequest + LockPath = $lockPath +} + +if ($AsObject) { + return $result +} + +$result | ConvertTo-Json -Depth 4 diff --git a/scripts/test-paired-engine.ps1 b/scripts/test-paired-engine.ps1 new file mode 100644 index 0000000..537d701 --- /dev/null +++ b/scripts/test-paired-engine.ps1 @@ -0,0 +1,77 @@ +[CmdletBinding()] +param( + [string] $EngineRoot = $(if ($env:ARIEC61850_ROOT) { $env:ARIEC61850_ROOT } else { Join-Path (Split-Path -Parent $PSScriptRoot) '..\ARIEC61850' }), + [switch] $SkipEngineTests, + [switch] $SkipCoverage +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$pin = & (Join-Path $root 'scripts\resolve-engine-pin.ps1') -RepositoryRoot $root -AsObject +$engineRootFull = [System.IO.Path]::GetFullPath($EngineRoot) +$engineSolution = Join-Path $engineRootFull 'ARIEC61850.sln' + +if (-not (Test-Path $engineSolution -PathType Leaf)) { + throw "ARIEC61850 sibling solution was not found: $engineSolution" +} + +$engineSha = (& git -C $engineRootFull rev-parse HEAD 2>&1 | Select-Object -First 1).Trim().ToLowerInvariant() +if ($LASTEXITCODE -ne 0) { + throw "Could not resolve ARIEC61850 HEAD at $engineRootFull." +} +if ($engineSha -ne $pin.Commit) { + throw "Paired engine mismatch. Expected $($pin.Commit), found $engineSha. Checkout the pinned commit before testing." +} + +$env:ARIEC61850_ROOT = $engineRootFull +$env:ARIEC61850_REF = $pin.Ref +$env:ARIEC61850_COMMIT = $pin.Commit + +function Invoke-DotNet { + param([Parameter(Mandatory)][string[]] $Arguments) + & dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } +} + +if (-not $SkipEngineTests) { + Write-Host '==> Restoring and testing pinned ARIEC61850' + Invoke-DotNet @('restore', $engineSolution) + Invoke-DotNet @('build', $engineSolution, '-c', 'Release', '--no-restore', '-warnaserror') + Invoke-DotNet @('test', $engineSolution, '-c', 'Release', '--no-build', '/p:TreatWarningsAsErrors=true') +} + +Write-Host '==> Building and testing ARSVIN applications against pinned engine' +& (Join-Path $root 'build.ps1') -EngineCommit $pin.Commit -EngineRef $pin.Ref +if ($LASTEXITCODE -ne 0) { + throw "Paired ARSVIN build failed with exit code $LASTEXITCODE." +} + +if (-not $SkipCoverage) { + Write-Host '==> Running ARSVIN integration coverage gates against pinned engine' + & (Join-Path $root 'scripts\test-with-coverage.ps1') -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore + if ($LASTEXITCODE -ne 0) { + throw "Paired coverage validation failed with exit code $LASTEXITCODE." + } +} + +$applicationSha = (& git -C $root rev-parse HEAD 2>&1 | Select-Object -First 1).Trim() +$reportRoot = Join-Path $root 'artifacts\paired-validation' +New-Item -ItemType Directory -Path $reportRoot -Force | Out-Null +[ordered]@{ + schemaVersion = 1 + result = 'passed' + applicationRepository = 'masarray/arsvin' + applicationCommit = $applicationSha + engineRepository = $pin.Repository + engineRef = $pin.Ref + engineCommit = $engineSha + engineTestsExecuted = (-not $SkipEngineTests) + coverageExecuted = (-not $SkipCoverage) + generatedAt = [DateTimeOffset]::UtcNow.ToString('O') +} | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $reportRoot 'paired-validation.json') -Encoding utf8 + +Write-Host "==> Paired validation passed: ARSVIN $applicationSha + ARIEC61850 $engineSha" From 3322bc71e8d1bdf6b0448c1d30c6ac3ec856548a Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 24 Jul 2026 07:57:19 +0700 Subject: [PATCH 60/61] Advance the pinned engine and correct IEC quality regression evidence --- engines/ARIEC61850.lock.json | 4 ++-- tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index bce1a71..d3d5381 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "agent/sv-core-unification", - "commit": "143e6ca69986cd553405eec883a9928cdfda9367", + "commit": "fcc05de1f4b11560195e2589aa032e1d4c4b95f7", "pairedPullRequest": 45, - "updatedAt": "2026-07-24T00:00:00Z", + "updatedAt": "2026-07-24T01:00:00Z", "purpose": "Pinned sibling engine revision for paired local, CI, CodeQL, packaging, and release validation." } diff --git a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs index 831dbc1..ca2ccd8 100644 --- a/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs +++ b/tests/ARSVIN.Tests/SampledValuesQualityAndRatioTests.cs @@ -19,8 +19,8 @@ public void QualityDecoderRecognizesAllZeroQualityAsGood() [Fact] public void QualityDecoderRecognizesInvalidFailureAsBad() { - // Validity bits 10b = Invalid, plus Failure bit 6. - var state = SvQualityDecoder.DecodeWord(0x0042); + // IEC 61850 validity bits 01b = Invalid, plus Failure bit 6. + var state = SvQualityDecoder.DecodeWord(0x0041); Assert.Equal(SvQualityValidity.Invalid, state.Validity); Assert.True(state.Failure); From 85e2c02f29a20447b23c297dbbc70a917cdee49e Mon Sep 17 00:00:00 2001 From: masarray Date: Fri, 24 Jul 2026 08:04:04 +0700 Subject: [PATCH 61/61] Gate paired coverage by exercised code instead of engine growth --- .github/workflows/ci.yml | 9 +++++-- scripts/test-paired-engine.ps1 | 6 ++++- scripts/test-with-coverage.ps1 | 48 ++++++++++++++++++++++++---------- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63dc7ba..0ce1227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,9 +156,14 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Test against pinned ARIEC61850 with coverage gates + - name: Test against pinned ARIEC61850 with integration coverage gates shell: pwsh - run: .\scripts\test-with-coverage.ps1 -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore + run: | + .\scripts\test-with-coverage.ps1 ` + -MinimumWholeEngineCoveredLines 3000 ` + -MinimumProtocolCoreCoveredLines 2300 ` + -MinimumLineCoverage 72.5 ` + -NoRestore - name: Upload test and coverage evidence if: always() diff --git a/scripts/test-paired-engine.ps1 b/scripts/test-paired-engine.ps1 index 537d701..0986b48 100644 --- a/scripts/test-paired-engine.ps1 +++ b/scripts/test-paired-engine.ps1 @@ -52,7 +52,11 @@ if ($LASTEXITCODE -ne 0) { if (-not $SkipCoverage) { Write-Host '==> Running ARSVIN integration coverage gates against pinned engine' - & (Join-Path $root 'scripts\test-with-coverage.ps1') -MinimumWholeEngineLineCoverage 14.25 -MinimumLineCoverage 72.5 -NoRestore + & (Join-Path $root 'scripts\test-with-coverage.ps1') ` + -MinimumWholeEngineCoveredLines 3000 ` + -MinimumProtocolCoreCoveredLines 2300 ` + -MinimumLineCoverage 72.5 ` + -NoRestore if ($LASTEXITCODE -ne 0) { throw "Paired coverage validation failed with exit code $LASTEXITCODE." } diff --git a/scripts/test-with-coverage.ps1 b/scripts/test-with-coverage.ps1 index c741792..288f089 100644 --- a/scripts/test-with-coverage.ps1 +++ b/scripts/test-with-coverage.ps1 @@ -3,8 +3,11 @@ param( [ValidateRange(0, 100)] [double] $MinimumLineCoverage = 72.5, - [ValidateRange(0, 100)] - [double] $MinimumWholeEngineLineCoverage = 14.25, + [ValidateRange(1, [int]::MaxValue)] + [int] $MinimumWholeEngineCoveredLines = 3000, + + [ValidateRange(1, [int]::MaxValue)] + [int] $MinimumProtocolCoreCoveredLines = 2300, [switch] $NoRestore ) @@ -33,8 +36,10 @@ if ($NoRestore) { $arguments += '--no-restore' } -# ARSVIN tests now execute against the sibling ARIEC61850 source of truth. -# Instrument the reusable core assembly rather than the retired embedded ARSVIN.Engine copy. +# ARSVIN tests execute against the pinned sibling ARIEC61850 source of truth. +# The engine repository owns its full-suite percentage gate. This integration gate checks: +# 1. the application-consumed protocol core remains strongly covered; and +# 2. the absolute amount of exercised production code does not regress when the engine grows. $arguments += @( '/p:RestoreLockedMode=true', '/p:TreatWarningsAsErrors=true', @@ -64,6 +69,7 @@ if (-not (Test-Path $coverageFile -PathType Leaf)) { [xml] $coverage = Get-Content $coverageFile -Raw $overallLineRateText = [string] $coverage.coverage.'line-rate' $overallLinesValidText = [string] $coverage.coverage.'lines-valid' +$overallLinesCoveredText = [string] $coverage.coverage.'lines-covered' if ([string]::IsNullOrWhiteSpace($overallLineRateText)) { throw 'Cobertura report does not contain a root line-rate value.' } @@ -73,6 +79,11 @@ if (-not [int]::TryParse($overallLinesValidText, [ref] $overallLinesValid) -or $ throw 'Coverage report contains no instrumented production source lines.' } +$overallLinesCovered = 0 +if (-not [int]::TryParse($overallLinesCoveredText, [ref] $overallLinesCovered) -or $overallLinesCovered -le 0) { + throw 'Coverage report contains no covered production source lines.' +} + $overallLineRate = [double]::Parse( $overallLineRateText, [System.Globalization.CultureInfo]::InvariantCulture @@ -130,37 +141,46 @@ if ($coreLinesValid -le 0) { $coreLineCoverage = [Math]::Round(($coreLinesCovered / $coreLinesValid) * 100, 2) -Write-Host "ARIEC61850 core lines: $overallLinesValid" -Write-Host "ARIEC61850 core line coverage: $overallLineCoverage%" -Write-Host "Whole-core minimum required: $MinimumWholeEngineLineCoverage%" +Write-Host "ARIEC61850 instrumented lines: $overallLinesValid" +Write-Host "ARIEC61850 covered lines exercised by ARSVIN: $overallLinesCovered" +Write-Host "Minimum covered production lines: $MinimumWholeEngineCoveredLines" +Write-Host "Informational whole-engine line coverage: $overallLineCoverage%" Write-Host "Protocol core files: $($coreFiles.Count)" Write-Host "Protocol core lines: $coreLinesValid" Write-Host "Protocol core covered lines: $coreLinesCovered" +Write-Host "Minimum protocol-core covered lines: $MinimumProtocolCoreCoveredLines" Write-Host "Protocol core line coverage: $coreLineCoverage%" -Write-Host "Protocol core minimum required: $MinimumLineCoverage%" +Write-Host "Protocol core minimum percentage: $MinimumLineCoverage%" Write-Host "Coverage report: $coverageFile" if ($env:GITHUB_STEP_SUMMARY) { @" -## Test coverage against sibling ARIEC61850 +## ARSVIN integration coverage against pinned ARIEC61850 + +The ARIEC61850 repository owns the full-engine percentage gate. This paired gate measures the reusable code actually exercised by ARSVIN Publisher and ArSubsv. | Metric | Result | |---|---:| | Whole `AR.Iec61850` instrumented lines | **$overallLinesValid** | -| Whole core line coverage | **$overallLineCoverage%** | -| Whole-core regression floor | **$MinimumWholeEngineLineCoverage%** | +| Production lines exercised by ARSVIN | **$overallLinesCovered** | +| Minimum exercised production lines | **$MinimumWholeEngineCoveredLines** | +| Informational whole-engine line coverage | **$overallLineCoverage%** | | Tested protocol-core files | **$($coreFiles.Count)** | | Protocol-core instrumented lines | **$coreLinesValid** | | Protocol-core covered lines | **$coreLinesCovered** | +| Minimum protocol-core covered lines | **$MinimumProtocolCoreCoveredLines** | | Protocol-core line coverage | **$coreLineCoverage%** | -| Protocol-core regression floor | **$MinimumLineCoverage%** | +| Protocol-core percentage floor | **$MinimumLineCoverage%** | | Report | `artifacts/test-results/coverage.cobertura.xml` | "@ | Add-Content $env:GITHUB_STEP_SUMMARY } $coverageFailures = [System.Collections.Generic.List[string]]::new() -if ($overallLineCoverage -lt $MinimumWholeEngineLineCoverage) { - $coverageFailures.Add("Whole-core line coverage $overallLineCoverage% is below the required $MinimumWholeEngineLineCoverage%.") +if ($overallLinesCovered -lt $MinimumWholeEngineCoveredLines) { + $coverageFailures.Add("ARSVIN exercised $overallLinesCovered production lines, below the required $MinimumWholeEngineCoveredLines.") +} +if ($coreLinesCovered -lt $MinimumProtocolCoreCoveredLines) { + $coverageFailures.Add("ARSVIN exercised $coreLinesCovered protocol-core lines, below the required $MinimumProtocolCoreCoveredLines.") } if ($coreLineCoverage -lt $MinimumLineCoverage) { $coverageFailures.Add("Protocol-core line coverage $coreLineCoverage% is below the required $MinimumLineCoverage%.")