diff --git a/Models/IoTesting/FatLiveValueObservation.cs b/Models/IoTesting/FatLiveValueObservation.cs new file mode 100644 index 000000000..e090af9bb --- /dev/null +++ b/Models/IoTesting/FatLiveValueObservation.cs @@ -0,0 +1,14 @@ +namespace ArIED61850Tester.Models.IoTesting; + +/// +/// Generic live IEC 61850 value image used by FAT v2. Unlike the legacy digital +/// observation model, the raw value is intentionally not normalized to bool. +/// +public sealed record FatLiveValueObservation( + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration); diff --git a/Models/IoTesting/FatVerificationModels.cs b/Models/IoTesting/FatVerificationModels.cs new file mode 100644 index 000000000..e4587e290 --- /dev/null +++ b/Models/IoTesting/FatVerificationModels.cs @@ -0,0 +1,170 @@ +using System.Text.Json.Serialization; + +namespace ArIED61850Tester.Models.IoTesting; + +/// +/// FAT v2 treats every static DataSet member as verification scope by default. +/// Inclusion is an operator decision and must never be changed by acquisition state. +/// +public enum FatSignalDisposition +{ + Included, + ExcludedByOperator +} + +public enum FatSignalKind +{ + Discrete, + Analog, + Other +} + +public enum FatCaptureMode +{ + AutomaticTransition, + OperatorSnapshot +} + +public enum FatValueSlot +{ + Value1, + Value2 +} + +public enum FatEvidenceCaptureKind +{ + AutomaticTransition, + OperatorSnapshot +} + +public sealed record FatValueEvidence( + Guid EvidenceId, + FatValueSlot Slot, + FatEvidenceCaptureKind CaptureKind, + string RawValue, + DateTimeOffset CapturedAt, + DateTimeOffset? IedTimestamp, + string Quality, + string AcquisitionSource, + long Sequence, + long ConnectionGeneration); + +/// +/// One FAT row. Identity follows static DataSet membership, not only the resolved runtime +/// reference, so the same IEC object may legitimately appear in more than one DataSet. +/// +public sealed class FatVerificationSignal +{ + [JsonInclude] + public FatSignalDisposition Disposition { get; private set; } = FatSignalDisposition.Included; + + public required string SignalId { get; init; } + public required string IedName { get; init; } + public string AccessPointName { get; init; } = string.Empty; + public required string DataSetReference { get; init; } + public int DataSetMemberIndex { get; init; } + public required string StaticMemberReference { get; init; } + public required string RuntimeReference { get; init; } + public string SignalName { get; init; } = string.Empty; + public string FunctionalConstraint { get; init; } = string.Empty; + public string DataType { get; init; } = string.Empty; + public FatSignalKind SignalKind { get; init; } = FatSignalKind.Other; + public FatCaptureMode CaptureMode { get; init; } = FatCaptureMode.OperatorSnapshot; + + [JsonInclude] + public FatValueEvidence? Value1Evidence { get; private set; } + + [JsonInclude] + public FatValueEvidence? Value2Evidence { get; private set; } + + [JsonIgnore] + public bool IsIncludedInFat => Disposition == FatSignalDisposition.Included; + + [JsonIgnore] + public bool HasCompleteEvidence => Value1Evidence is not null && Value2Evidence is not null; + + /// + /// Explicit operator action. Removing a row never destroys evidence or source identity. + /// + public void RemoveFromFat() => Disposition = FatSignalDisposition.ExcludedByOperator; + + /// + /// Explicit operator action. Restoring a row returns the same row and evidence to scope. + /// + public void RestoreToFat() => Disposition = FatSignalDisposition.Included; + + public void SetCurrentEvidence(FatValueEvidence evidence) + { + ArgumentNullException.ThrowIfNull(evidence); + if (evidence.Slot == FatValueSlot.Value1) + Value1Evidence = evidence; + else + Value2Evidence = evidence; + } +} + +public sealed class FatVerificationProject +{ + public string ProjectId { get; init; } = Guid.NewGuid().ToString("N"); + public List Signals { get; init; } = new(); + + [JsonIgnore] + public IReadOnlyList IncludedSignals => + Signals.Where(signal => signal.IsIncludedInFat).ToArray(); + + [JsonIgnore] + public IReadOnlyList RemovedSignals => + Signals.Where(signal => !signal.IsIncludedInFat).ToArray(); + + public bool RemoveSignal(string signalId) + { + var signal = FindSignal(signalId); + if (signal is null) + return false; + signal.RemoveFromFat(); + return true; + } + + public bool RestoreSignal(string signalId) + { + var signal = FindSignal(signalId); + if (signal is null) + return false; + signal.RestoreToFat(); + return true; + } + + public int RestoreSignals(IEnumerable signalIds) + { + ArgumentNullException.ThrowIfNull(signalIds); + var requested = signalIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + var restored = 0; + foreach (var signal in Signals) + { + if (!requested.Contains(signal.SignalId) || signal.IsIncludedInFat) + continue; + signal.RestoreToFat(); + restored++; + } + return restored; + } + + public int RestoreAllSignals() + { + var restored = 0; + foreach (var signal in Signals.Where(signal => !signal.IsIncludedInFat)) + { + signal.RestoreToFat(); + restored++; + } + return restored; + } + + private FatVerificationSignal? FindSignal(string signalId) + { + if (string.IsNullOrWhiteSpace(signalId)) + return null; + return Signals.FirstOrDefault(signal => + signal.SignalId.Equals(signalId, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Services/IoTesting/FatDataSetSignalProjectionService.cs b/Services/IoTesting/FatDataSetSignalProjectionService.cs new file mode 100644 index 000000000..f3e41c8ab --- /dev/null +++ b/Services/IoTesting/FatDataSetSignalProjectionService.cs @@ -0,0 +1,155 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// ARSAS FAT v2 consumer adapter over ARIEC-owned static DataSet authority. +/// +/// This service does not parse SCL, infer FCDA semantics, or deduplicate by runtime object +/// reference. ARIEC remains the authority for mandatory DataSet descriptors; ARSAS creates +/// exactly one FAT row per static DataSet membership. +/// +public static class FatDataSetSignalProjectionService +{ + public static IReadOnlyList Project(SclIedWorkspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + ArgumentNullException.ThrowIfNull(workspace.DesignModel); + return Project(workspace.IedName, workspace.AccessPointName, workspace.DesignModel); + } + + public static IReadOnlyList Project( + string iedName, + string accessPointName, + LiveIedModelDiscoveryDocument model) + { + ArgumentException.ThrowIfNullOrWhiteSpace(iedName); + ArgumentNullException.ThrowIfNull(model); + + var descriptors = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var rows = descriptors + .SelectMany(descriptor => descriptor.DataSetMemberships.Select(membership => + CreateRow(iedName.Trim(), accessPointName?.Trim() ?? string.Empty, descriptor, membership))) + .OrderBy(row => row.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(row => row.DataSetMemberIndex) + .ThenBy(row => row.StaticMemberReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var staticMemberCount = model.DataSets.Sum(dataSet => dataSet.Members.Count); + if (rows.Length != staticMemberCount) + { + throw new InvalidDataException( + $"ARIEC static DataSet projection returned {rows.Length} FAT membership row(s), " + + $"but the SCL design model contains {staticMemberCount}. FAT import refuses to silently drop or invent DataSet members."); + } + + var duplicateIdentity = rows + .GroupBy(row => row.SignalId, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateIdentity is not null) + { + throw new InvalidDataException( + $"Static DataSet membership identity is not unique for FAT signal '{duplicateIdentity.Key}'."); + } + + return rows; + } + + private static FatVerificationSignal CreateRow( + string iedName, + string accessPointName, + Iec61850SignalDescriptor descriptor, + Iec61850SignalDataSetMembership membership) + { + var staticReference = FirstNonEmpty( + membership.CanonicalMemberReference, + membership.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + var runtimeReference = FirstNonEmpty( + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference, + staticReference); + var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); + var signalKind = Classify(descriptor.FunctionalConstraint, dataType); + + return new FatVerificationSignal + { + SignalId = BuildSignalId( + iedName, + accessPointName, + membership.DataSetReference, + membership.MemberIndex, + staticReference), + IedName = iedName, + AccessPointName = accessPointName, + DataSetReference = membership.DataSetReference, + DataSetMemberIndex = membership.MemberIndex, + StaticMemberReference = staticReference, + RuntimeReference = runtimeReference, + SignalName = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference), + FunctionalConstraint = descriptor.FunctionalConstraint, + DataType = dataType, + SignalKind = signalKind, + CaptureMode = signalKind == FatSignalKind.Discrete + ? FatCaptureMode.AutomaticTransition + : FatCaptureMode.OperatorSnapshot + }; + } + + internal static FatSignalKind Classify(string? functionalConstraint, string? dataType) + { + var fc = (functionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + var type = (dataType ?? string.Empty).Trim().ToUpperInvariant(); + + if (fc == "MX" || + type.Contains("FLOAT", StringComparison.Ordinal) || + type.Contains("DOUBLE", StringComparison.Ordinal) || + type.Contains("ANALOG", StringComparison.Ordinal)) + { + return FatSignalKind.Analog; + } + + if (type.Contains("BOOLEAN", StringComparison.Ordinal) || + type.Contains("BOOL", StringComparison.Ordinal) || + type.Contains("DBPOS", StringComparison.Ordinal) || + type.Contains("DOUBLE-POINT", StringComparison.Ordinal) || + type.Contains("DOUBLE POINT", StringComparison.Ordinal)) + { + return FatSignalKind.Discrete; + } + + // ST is intentionally not assumed to be boolean by itself. Enumerated/integer ST + // values need an explicit discrete semantic before automatic two-state capture. + return FatSignalKind.Other; + } + + private static string BuildSignalId( + string iedName, + string accessPointName, + string dataSetReference, + int memberIndex, + string staticReference) + { + var identity = string.Join("|", new[] + { + iedName.Trim(), + accessPointName.Trim(), + dataSetReference.Trim(), + memberIndex.ToString(CultureInfo.InvariantCulture), + staticReference.Trim() + }); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(identity)); + return "fat-" + Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} diff --git a/Services/IoTesting/FatOperatorSnapshotCaptureService.cs b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs new file mode 100644 index 000000000..cdd03fd3b --- /dev/null +++ b/Services/IoTesting/FatOperatorSnapshotCaptureService.cs @@ -0,0 +1,57 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Captures the value currently read from the IED when the FAT operator explicitly +/// confirms Value 1 or Value 2. No injected/reference value or tolerance exists here; +/// this first public milestone proves trustworthy reading capture only. +/// +public static class FatOperatorSnapshotCaptureService +{ + public static FatValueEvidence Capture( + FatVerificationSignal signal, + FatValueSlot slot, + FatLiveValueObservation observation) + { + ArgumentNullException.ThrowIfNull(signal); + ArgumentNullException.ThrowIfNull(observation); + + if (!signal.IsIncludedInFat) + { + throw new InvalidOperationException( + $"FAT signal '{signal.SignalName}' is removed from the active FAT scope and cannot capture evidence until it is restored."); + } + + if (signal.CaptureMode != FatCaptureMode.OperatorSnapshot) + { + throw new InvalidOperationException( + $"FAT signal '{signal.SignalName}' uses {signal.CaptureMode} capture and cannot be manually snapshotted."); + } + + if (string.IsNullOrWhiteSpace(observation.RawValue) || + observation.RawValue.Trim() is "-" or "—") + { + throw new InvalidOperationException( + $"FAT signal '{signal.SignalName}' does not have a readable live value to capture."); + } + + var evidence = new FatValueEvidence( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.OperatorSnapshot, + observation.RawValue.Trim(), + observation.CapturedAt, + observation.IedTimestamp, + string.IsNullOrWhiteSpace(observation.Quality) ? "Unknown" : observation.Quality.Trim(), + string.IsNullOrWhiteSpace(observation.AcquisitionSource) ? "Unknown" : observation.AcquisitionSource.Trim(), + observation.Sequence, + observation.ConnectionGeneration); + + // This intentionally replaces only the current Value 1/Value 2 evidence pointer. + // The session/journal adapter remains responsible for appending every capture to + // immutable historical evidence before this service is wired into the public UI. + signal.SetCurrentEvidence(evidence); + return evidence; + } +} diff --git a/Services/IoTesting/FatSclWorkspaceImportService.cs b/Services/IoTesting/FatSclWorkspaceImportService.cs new file mode 100644 index 000000000..c2e4bcc45 --- /dev/null +++ b/Services/IoTesting/FatSclWorkspaceImportService.cs @@ -0,0 +1,134 @@ +using System.Security.Cryptography; +using System.Text; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record FatSclWorkspaceSource( + string SourceFileName, + string SourceSha256, + SclIedWorkspace Workspace); + +public sealed record FatSclSourceArtifact( + string SourceFileName, + string SourceSha256, + string IedName, + string AccessPointName); + +public sealed record FatSclImportResult( + FatVerificationProject Project, + IReadOnlyList Sources, + string SourceFingerprint); + +/// +/// Aggregates one or many engine-created SCL workspaces into a FAT v2 project. +/// The caller owns file selection and SclWorkspaceService.OpenAsync; this layer only consumes +/// ARIEC workspaces and never parses XML itself. +/// +public static class FatSclWorkspaceImportService +{ + public static FatSclImportResult Import(IEnumerable workspaceSources) + { + ArgumentNullException.ThrowIfNull(workspaceSources); + var supplied = workspaceSources.ToArray(); + if (supplied.Length == 0) + throw new InvalidDataException("Select at least one SCL workspace for FAT import."); + + foreach (var source in supplied) + ValidateSource(source); + + var selected = new List(); + foreach (var identityGroup in supplied.GroupBy( + source => WorkspaceIdentity(source.Workspace), + StringComparer.OrdinalIgnoreCase)) + { + var distinctHashes = identityGroup + .Select(source => NormalizeHash(source.SourceSha256)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (distinctHashes.Length > 1) + { + throw new InvalidDataException( + $"Conflicting SCL sources define the same IED/AccessPoint '{identityGroup.Key}'. " + + "FAT import will not silently merge competing engineering authorities."); + } + + // The same file may be selected twice or supplied through two equivalent paths. + // Exact content duplicates are harmless and collapse to one workspace identity. + selected.Add(identityGroup + .OrderBy(source => source.SourceFileName, StringComparer.OrdinalIgnoreCase) + .First()); + } + + var rows = selected + .SelectMany(source => FatDataSetSignalProjectionService.Project(source.Workspace)) + .OrderBy(signal => signal.IedName, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.AccessPointName, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.DataSetMemberIndex) + .ToList(); + + var duplicateSignal = rows + .GroupBy(signal => signal.SignalId, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateSignal is not null) + throw new InvalidDataException($"Duplicate FAT signal identity '{duplicateSignal.Key}' was produced during SCL aggregation."); + + var sources = selected + .Select(source => new FatSclSourceArtifact( + source.SourceFileName.Trim(), + NormalizeHash(source.SourceSha256), + source.Workspace.IedName.Trim(), + source.Workspace.AccessPointName.Trim())) + .OrderBy(source => source.SourceSha256, StringComparer.OrdinalIgnoreCase) + .ThenBy(source => source.IedName, StringComparer.OrdinalIgnoreCase) + .ThenBy(source => source.AccessPointName, StringComparer.OrdinalIgnoreCase) + .ThenBy(source => source.SourceFileName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new FatSclImportResult( + new FatVerificationProject { Signals = rows }, + sources, + BuildFingerprint(sources)); + } + + private static void ValidateSource(FatSclWorkspaceSource source) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(source.Workspace); + ArgumentNullException.ThrowIfNull(source.Workspace.DesignModel); + if (string.IsNullOrWhiteSpace(source.SourceFileName)) + throw new InvalidDataException("SCL source file name is required for FAT provenance."); + if (string.IsNullOrWhiteSpace(source.Workspace.IedName) || + string.IsNullOrWhiteSpace(source.Workspace.AccessPointName)) + { + throw new InvalidDataException("Every FAT SCL workspace requires explicit IED and AccessPoint identity."); + } + + var hash = NormalizeHash(source.SourceSha256); + if (hash.Length != 64 || hash.Any(ch => !Uri.IsHexDigit(ch))) + throw new InvalidDataException($"SCL source '{source.SourceFileName}' does not have a valid SHA-256 provenance hash."); + } + + private static string BuildFingerprint(IEnumerable sources) + { + // File ordering must not affect project identity. File names are deliberately omitted + // from the hash so identical bytes selected from a renamed path remain the same source. + var canonical = string.Join("\n", sources + .Select(source => string.Join("|", new[] + { + source.SourceSha256.ToLowerInvariant(), + source.IedName.Trim().ToLowerInvariant(), + source.AccessPointName.Trim().ToLowerInvariant() + })) + .OrderBy(value => value, StringComparer.Ordinal)); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + + private static string WorkspaceIdentity(SclIedWorkspace workspace) + => $"{workspace.IedName.Trim()}|{workspace.AccessPointName.Trim()}"; + + private static string NormalizeHash(string value) + => (value ?? string.Empty).Trim().ToLowerInvariant(); +} diff --git a/docs/FAT_V2_IMPLEMENTATION_PLAN.md b/docs/FAT_V2_IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..2db841587 --- /dev/null +++ b/docs/FAT_V2_IMPLEMENTATION_PLAN.md @@ -0,0 +1,123 @@ +# FAT v2 — DataSet-driven verification implementation plan + +Status: implementation branch `feat/fat-dataset-verification-v1` + +## Immutable baseline + +- ARSAS base `main`: `da5d4a11ab5e04686c766c8920657322a2350636` +- ARIEC61850 engine pin consumed by ARSAS: `26c85400a4da230c4429e6302847f230385b6687` +- ARIEC61850 newer `main` was audited but is not consumed by this work unless a missing engine API is proven. The newer delta is reporting/runtime work and would unnecessarily widen the regression surface. + +## Product contract + +FAT v2 is a DataSet-driven IEC 61850 verification workspace. + +1. Every static DataSet membership becomes a FAT row by default. +2. Digital, analog, and other DataSet members are never silently dropped because of signal type. +3. Source SCL is immutable. ARSAS consumes ARIEC SCL/workspace projections and does not parse or rewrite SCL for FAT. +4. A user may explicitly remove a FAT row. Removal changes only operator disposition; source identity and evidence remain preserved. +5. Removed rows can be restored individually or in bulk. +6. FAT uses generic `Value 1` and `Value 2` evidence. +7. Discrete values use automatic transition capture. +8. Analog values use explicit operator snapshot capture from the current live IED reading. +9. Recapture may replace the current Value 1/Value 2 pointer, while the historical evidence journal remains append-only. +10. Acquisition/session state must never mutate operator inclusion/disposition. + +## Explicitly deferred + +The first public milestone does **not** include: + +- injected/reference values; +- tolerance configuration; +- percent-error calculation; +- automatic analog pass/fail against injected values. + +Those features are allowed only after the base capture workflow has public-quality evidence. + +## Phase gates + +### P0 — Baseline and engine authority + +- [x] Establish exact ARSAS `main` SHA. +- [x] Establish exact ARIEC `main` SHA and exact ARSAS engine pin. +- [x] Compare pin to newer engine head. +- [x] Keep the proven engine pin because static DataSet authority already exists. + +Gate: no engine pin movement without a concrete missing API and dedicated ARIEC regression evidence. + +### P1 — Additive FAT v2 domain + +- [x] Add static membership identity, signal kind, capture mode, operator disposition. +- [x] Add generic Value 1 / Value 2 evidence primitives. +- [x] Add reversible remove/restore and bulk restore domain operations. +- [x] Project exactly one row per ARIEC static DataSet membership. +- [x] Keep duplicate membership identity across different DataSets. +- [x] Add analog operator snapshot capture primitive. +- [x] Add deterministic multi-workspace aggregation and conflict policy. +- [ ] All focused IO FAT CI gates green at the current branch head. + +Gate: existing FAT production files stay untouched until the additive domain compiles and regressions pass. + +### P2 — Evidence/session bridge + +- Replace legacy ON/OFF-only session assumptions with Value 1 / Value 2 adapters. +- Digital adapter keeps proven transition semantics while emitting generic evidence. +- Analog adapter snapshots current raw live value only after explicit operator action. +- Append every capture/recapture to the hash-chained journal before replacing current evidence pointers. +- Stop using `TestEnabled` as an internal engine/session-scope switch. +- Completed selected rows remain selected; operator Stop seals the active session. + +Required regressions: + +- restored completed row preserves operator inclusion; +- package open preserves operator inclusion; +- clean retest does not change operator inclusion; +- analog V1/V2 capture completes a row; +- analog recapture replaces current pointer and journals both captures; +- discrete recapture updates current evidence without losing historical proof; +- removed row receives no new evidence until restored. + +### P3 — SCL source and persistence contract + +- Add source kind and source artifact collection while retaining legacy workbook compatibility. +- Import one or many SCL files through `SclWorkspaceService.OpenAsync` only. +- Preserve source file SHA-256 plus IED/AccessPoint provenance. +- Use a stable combined fingerprint independent of file selection order. +- Block conflicting definitions of the same IED/AccessPoint instead of fuzzy merging. +- Allow SCL without Communication/IP to import; endpoint binding is required only before live acquisition. + +Required regressions: + +- one SCL imports all static DataSet members; +- multiple SCL files aggregate all unique IED/AP workspaces; +- identical duplicate source collapses safely; +- conflicting IED/AP sources block; +- fingerprint is order-independent; +- Siemens-like 36 ST + 22 MX fixture remains 58/58. + +### P4 — FAT workspace UX + +- Replace ON/OFF headers with Value 1 / Value 2. +- Digital rows show automatic captured values. +- Analog rows show current live value and compact capture check action for each slot. +- Right-click row: `Remove from FAT`. +- Add `Removed Signals (n)` command. +- Removed Signals window supports search, row selection, Select All, Deselect All, Restore Selected, and single-row restore. +- Remove/restore never clears evidence. + +Gate: compact UI, keyboard-safe actions, no destructive SCL mutation, no hidden auto-selection changes. + +### P5 — Package, report, compatibility and release proof + +- Persist operator disposition and Value 1/Value 2 current evidence. +- Package all SCL source artifacts in `.arsas` projects without pretending an SCL project has a source workbook. +- Keep legacy Excel FAT projects readable. +- Export PDF/Excel reports with generic Value 1 / Value 2 and signal kind. +- Update FAT documentation. +- Run full Build ARSAS, focused IO FAT, existing SCL/DataSet regressions, package integrity tests, and release guards. + +Gate: no merge to `main` until all required CI checks are green and the PR diff is audited for accidental legacy behavior changes. + +## Commit policy + +Use narrow commits with one invariant per slice. Prefer additive code until regression tests establish the replacement contract. Do not mix ARIEC engine evolution, unrelated reporting work, or visual refactors into FAT v2 commits. Any engine change must land in ARIEC first with its own proof, then ARSAS may deliberately update the immutable lock in a separate consumer commit. diff --git a/tests/ARSAS.Tests/FatDataSetProjectionRegressionTests.cs b/tests/ARSAS.Tests/FatDataSetProjectionRegressionTests.cs new file mode 100644 index 000000000..2418e0e74 --- /dev/null +++ b/tests/ARSAS.Tests/FatDataSetProjectionRegressionTests.cs @@ -0,0 +1,157 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Engineering; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatDataSetProjectionRegressionTests +{ + [Fact] + public void StaticDataSet_DigitalAndAnalogMembers_AllBecomeFatRows() + { + var model = SclLiveModelProjectionBuilder.Build(BuildFixture(), "fat_digital_analog.cid"); + + var rows = FatDataSetSignalProjectionService.Project("IED1", "S1", model); + + Assert.Equal(2, model.DataSets.Sum(dataSet => dataSet.Members.Count)); + Assert.Equal(2, rows.Count); + Assert.All(rows, row => Assert.True(row.IsIncludedInFat)); + + var digital = Assert.Single(rows.Where(row => row.StaticMemberReference.Contains("GGIO1.Dig01", StringComparison.Ordinal))); + Assert.Equal(FatSignalKind.Discrete, digital.SignalKind); + Assert.Equal(FatCaptureMode.AutomaticTransition, digital.CaptureMode); + Assert.Equal("ST", digital.FunctionalConstraint); + + var analog = Assert.Single(rows.Where(row => row.StaticMemberReference.Contains("MMXU1.Ana01", StringComparison.Ordinal))); + Assert.Equal(FatSignalKind.Analog, analog.SignalKind); + Assert.Equal(FatCaptureMode.OperatorSnapshot, analog.CaptureMode); + Assert.Equal("MX", analog.FunctionalConstraint); + } + + [Fact] + public void SameRuntimeObject_InTwoDataSets_RemainsTwoFatMembershipRows() + { + var model = SclLiveModelProjectionBuilder.Build(BuildDuplicateMembershipFixture(), "fat_duplicate_membership.cid"); + + var rows = FatDataSetSignalProjectionService.Project("IED1", "S1", model); + + Assert.Equal(2, rows.Count); + Assert.Equal(2, rows.Select(row => row.DataSetReference).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(2, rows.Select(row => row.SignalId).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.All(rows, row => Assert.Contains("GGIO1.Dig01", row.StaticMemberReference, StringComparison.Ordinal)); + } + + private static XDocument BuildFixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + return BuildScl(ns, + new XElement(ns + "DataSet", + new XAttribute("name", "FAT"), + Fcda(ns, "ADD", "GGIO", "1", "Dig01", "ST"), + Fcda(ns, "MEAS", "MMXU", "1", "Ana01", "MX"))); + } + + private static XDocument BuildDuplicateMembershipFixture() + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + return BuildScl(ns, + new XElement(ns + "DataSet", + new XAttribute("name", "FAT_A"), + Fcda(ns, "ADD", "GGIO", "1", "Dig01", "ST")), + new XElement(ns + "DataSet", + new XAttribute("name", "FAT_B"), + Fcda(ns, "ADD", "GGIO", "1", "Dig01", "ST"))); + } + + private static XElement Fcda( + XNamespace ns, + string ldInst, + string lnClass, + string lnInst, + string doName, + string fc) + => new(ns + "FCDA", + new XAttribute("ldInst", ldInst), + new XAttribute("lnClass", lnClass), + new XAttribute("lnInst", lnInst), + new XAttribute("doName", doName), + new XAttribute("fc", fc)); + + private static XDocument BuildScl(XNamespace ns, params XElement[] dataSets) + { + var ln0 = new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")); + foreach (var dataSet in dataSets) + ln0.Add(dataSet); + + return new XDocument( + new XElement(ns + "SCL", + new XAttribute("version", "2007"), + new XAttribute("revision", "B"), + new XElement(ns + "Header", new XAttribute("id", "FAT_V2")), + new XElement(ns + "IED", + new XAttribute("name", "IED1"), + new XElement(ns + "AccessPoint", + new XAttribute("name", "S1"), + new XElement(ns + "Server", + new XElement(ns + "LDevice", + new XAttribute("inst", "Application"), + ln0), + new XElement(ns + "LDevice", + new XAttribute("inst", "ADD"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", + new XAttribute("lnClass", "GGIO"), + new XAttribute("inst", "1"), + new XAttribute("lnType", "GGIOType"))), + new XElement(ns + "LDevice", + new XAttribute("inst", "MEAS"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", + new XAttribute("lnClass", "MMXU"), + new XAttribute("inst", "1"), + new XAttribute("lnType", "MMXUType")))))), + new XElement(ns + "DataTypeTemplates", + new XElement(ns + "LNodeType", + new XAttribute("id", "LLN0Type"), + new XAttribute("lnClass", "LLN0")), + new XElement(ns + "LNodeType", + new XAttribute("id", "GGIOType"), + new XAttribute("lnClass", "GGIO"), + new XElement(ns + "DO", + new XAttribute("name", "Dig01"), + new XAttribute("type", "SpsType"))), + new XElement(ns + "LNodeType", + new XAttribute("id", "MMXUType"), + new XAttribute("lnClass", "MMXU"), + new XElement(ns + "DO", + new XAttribute("name", "Ana01"), + new XAttribute("type", "MvType"))), + new XElement(ns + "DOType", + new XAttribute("id", "SpsType"), + new XAttribute("cdc", "SPS"), + new XElement(ns + "DA", + new XAttribute("name", "stVal"), + new XAttribute("bType", "BOOLEAN"), + new XAttribute("fc", "ST"))), + new XElement(ns + "DOType", + new XAttribute("id", "MvType"), + new XAttribute("cdc", "MV"), + new XElement(ns + "DA", + new XAttribute("name", "mag"), + new XAttribute("bType", "Struct"), + new XAttribute("type", "AnalogueValue"), + new XAttribute("fc", "MX"))), + new XElement(ns + "DAType", + new XAttribute("id", "AnalogueValue"), + new XElement(ns + "BDA", + new XAttribute("name", "f"), + new XAttribute("bType", "FLOAT32")))))); + } +} diff --git a/tests/ARSAS.Tests/FatOperatorSnapshotCaptureTests.cs b/tests/ARSAS.Tests/FatOperatorSnapshotCaptureTests.cs new file mode 100644 index 000000000..16eb8793c --- /dev/null +++ b/tests/ARSAS.Tests/FatOperatorSnapshotCaptureTests.cs @@ -0,0 +1,120 @@ +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatOperatorSnapshotCaptureTests +{ + [Fact] + public void AnalogOperatorCapture_StoresValue1ThenValue2_AndCompletesEvidence() + { + var signal = AnalogSignal(); + + var value1 = FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("99.72 A", 1)); + var value2 = FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value2, + Observation("498.61 A", 2)); + + Assert.Equal("99.72 A", value1.RawValue); + Assert.Equal("498.61 A", value2.RawValue); + Assert.Same(value1, signal.Value1Evidence); + Assert.Same(value2, signal.Value2Evidence); + Assert.True(signal.HasCompleteEvidence); + Assert.True(signal.IsIncludedInFat); + } + + [Fact] + public void RecapturingSameSlot_ReplacesCurrentPointer_WithoutChangingInclusion() + { + var signal = AnalogSignal(); + var first = FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("99.72 A", 1)); + var replacement = FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("100.03 A", 2)); + + Assert.NotEqual(first.EvidenceId, replacement.EvidenceId); + Assert.Same(replacement, signal.Value1Evidence); + Assert.Null(signal.Value2Evidence); + Assert.True(signal.IsIncludedInFat); + } + + [Fact] + public void RemovedSignal_CannotCaptureUntilExplicitlyRestored() + { + var signal = AnalogSignal(); + signal.RemoveFromFat(); + + Assert.Throws(() => + FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("99.72 A", 1))); + + signal.RestoreToFat(); + var captured = FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("99.72 A", 2)); + Assert.Same(captured, signal.Value1Evidence); + } + + [Fact] + public void AutomaticDigitalSignal_CannotBeManuallySnapshotted() + { + var signal = DigitalSignal(); + + Assert.Throws(() => + FatOperatorSnapshotCaptureService.Capture( + signal, + FatValueSlot.Value1, + Observation("true", 1))); + } + + private static FatVerificationSignal DigitalSignal() + => new() + { + SignalId = "fat-digital", + IedName = "IED1", + DataSetReference = "IED1LD0/LLN0$DS", + StaticMemberReference = "IED1ADD/GGIO1.Dig01", + RuntimeReference = "IED1ADD/GGIO1.Dig01.stVal", + SignalName = "Dig01", + FunctionalConstraint = "ST", + DataType = "BOOLEAN", + SignalKind = FatSignalKind.Discrete, + CaptureMode = FatCaptureMode.AutomaticTransition + }; + + private static FatVerificationSignal AnalogSignal() + => new() + { + SignalId = "fat-analog", + IedName = "IED1", + DataSetReference = "IED1LD0/LLN0$DS", + StaticMemberReference = "IED1MEAS/MMXU1.A.phsA", + RuntimeReference = "IED1MEAS/MMXU1.A.phsA.cVal.mag.f", + SignalName = "A.phsA", + FunctionalConstraint = "MX", + DataType = "FLOAT32", + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot + }; + + private static FatLiveValueObservation Observation(string value, long sequence) + => new( + value, + new DateTimeOffset(2026, 8, 31, 10, 0, 0, TimeSpan.Zero).AddSeconds(sequence), + new DateTimeOffset(2026, 8, 31, 9, 59, 59, TimeSpan.Zero).AddSeconds(sequence), + "good", + "MMS", + sequence, + 1); +} diff --git a/tests/ARSAS.Tests/FatSclWorkspaceImportTests.cs b/tests/ARSAS.Tests/FatSclWorkspaceImportTests.cs new file mode 100644 index 000000000..c6799a944 --- /dev/null +++ b/tests/ARSAS.Tests/FatSclWorkspaceImportTests.cs @@ -0,0 +1,117 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Engineering; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatSclWorkspaceImportTests +{ + [Fact] + public void MultipleSclWorkspaces_AggregateAllRows_AndFingerprintIgnoresSelectionOrder() + { + var first = Source("relay-a.cid", Hash('a'), BuildWorkspace("IED_A", "S1", "Dig01")); + var second = Source("relay-b.cid", Hash('b'), BuildWorkspace("IED_B", "S1", "Dig02")); + + var forward = FatSclWorkspaceImportService.Import(new[] { first, second }); + var reverse = FatSclWorkspaceImportService.Import(new[] { second, first }); + + Assert.Equal(2, forward.Project.Signals.Count); + Assert.Equal(2, forward.Sources.Count); + Assert.Equal(forward.SourceFingerprint, reverse.SourceFingerprint); + Assert.Contains(forward.Project.Signals, signal => signal.IedName == "IED_A"); + Assert.Contains(forward.Project.Signals, signal => signal.IedName == "IED_B"); + } + + [Fact] + public void SameIedAccessPoint_FromDifferentSourceHashes_IsBlocked() + { + var first = Source("relay-old.cid", Hash('a'), BuildWorkspace("IED_A", "S1", "Dig01")); + var second = Source("relay-new.cid", Hash('b'), BuildWorkspace("IED_A", "S1", "Dig01")); + + var error = Assert.Throws(() => + FatSclWorkspaceImportService.Import(new[] { first, second })); + + Assert.Contains("Conflicting SCL sources", error.Message, StringComparison.Ordinal); + Assert.Contains("IED_A|S1", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ExactDuplicateWorkspaceContent_CollapsesWithoutDuplicatingFatRows() + { + var workspace = BuildWorkspace("IED_A", "S1", "Dig01"); + var first = Source("relay-a.cid", Hash('a'), workspace); + var duplicate = Source("copy-of-relay-a.cid", Hash('a'), workspace); + + var result = FatSclWorkspaceImportService.Import(new[] { duplicate, first }); + + Assert.Single(result.Sources); + Assert.Single(result.Project.Signals); + } + + private static FatSclWorkspaceSource Source(string fileName, string hash, SclIedWorkspace workspace) + => new(fileName, hash, workspace); + + private static string Hash(char value) => new(value, 64); + + private static SclIedWorkspace BuildWorkspace(string iedName, string accessPointName, string dataObjectName) + { + XNamespace ns = "http://www.iec.ch/61850/2003/SCL"; + var document = new XDocument( + new XElement(ns + "SCL", + new XAttribute("version", "2007"), + new XAttribute("revision", "B"), + new XElement(ns + "Header", new XAttribute("id", "FAT_MULTI")), + new XElement(ns + "IED", + new XAttribute("name", iedName), + new XElement(ns + "AccessPoint", + new XAttribute("name", accessPointName), + new XElement(ns + "Server", + new XElement(ns + "LDevice", + new XAttribute("inst", "Application"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type"), + new XElement(ns + "DataSet", + new XAttribute("name", "FAT"), + new XElement(ns + "FCDA", + new XAttribute("ldInst", "ADD"), + new XAttribute("lnClass", "GGIO"), + new XAttribute("lnInst", "1"), + new XAttribute("doName", dataObjectName), + new XAttribute("fc", "ST"))))), + new XElement(ns + "LDevice", + new XAttribute("inst", "ADD"), + new XElement(ns + "LN0", + new XAttribute("lnClass", "LLN0"), + new XAttribute("lnType", "LLN0Type")), + new XElement(ns + "LN", + new XAttribute("lnClass", "GGIO"), + new XAttribute("inst", "1"), + new XAttribute("lnType", "GGIOType")))))), + new XElement(ns + "DataTypeTemplates", + new XElement(ns + "LNodeType", + new XAttribute("id", "LLN0Type"), + new XAttribute("lnClass", "LLN0")), + new XElement(ns + "LNodeType", + new XAttribute("id", "GGIOType"), + new XAttribute("lnClass", "GGIO"), + new XElement(ns + "DO", + new XAttribute("name", dataObjectName), + new XAttribute("type", "SpsType"))), + new XElement(ns + "DOType", + new XAttribute("id", "SpsType"), + new XAttribute("cdc", "SPS"), + new XElement(ns + "DA", + new XAttribute("name", "stVal"), + new XAttribute("bType", "BOOLEAN"), + new XAttribute("fc", "ST")))))); + + return new SclIedWorkspace + { + IedName = iedName, + AccessPointName = accessPointName, + DesignModel = SclLiveModelProjectionBuilder.Build(document, $"{iedName}.cid") + }; + } +} diff --git a/tests/ARSAS.Tests/FatVerificationDomainRegressionTests.cs b/tests/ARSAS.Tests/FatVerificationDomainRegressionTests.cs new file mode 100644 index 000000000..cbd9f79d2 --- /dev/null +++ b/tests/ARSAS.Tests/FatVerificationDomainRegressionTests.cs @@ -0,0 +1,97 @@ +using ArIED61850Tester.Models.IoTesting; + +namespace ARSAS.Tests; + +public sealed class FatVerificationDomainRegressionTests +{ + [Fact] + public void RemoveAndRestore_AreOperatorDispositionOnly_AndPreserveEvidence() + { + var signal = BuildSignal("fat-1"); + var value1 = Evidence(FatValueSlot.Value1, "99.72 A"); + var value2 = Evidence(FatValueSlot.Value2, "498.61 A"); + signal.SetCurrentEvidence(value1); + signal.SetCurrentEvidence(value2); + var project = new FatVerificationProject { Signals = new List { signal } }; + + Assert.True(project.RemoveSignal(signal.SignalId)); + Assert.Empty(project.IncludedSignals); + Assert.Single(project.RemovedSignals); + Assert.Same(value1, signal.Value1Evidence); + Assert.Same(value2, signal.Value2Evidence); + + Assert.True(project.RestoreSignal(signal.SignalId)); + Assert.Single(project.IncludedSignals); + Assert.Empty(project.RemovedSignals); + Assert.Same(value1, signal.Value1Evidence); + Assert.Same(value2, signal.Value2Evidence); + } + + [Fact] + public void RestoreSignals_SupportsBulkSelection_WithoutTouchingUnselectedRows() + { + var a = BuildSignal("fat-a"); + var b = BuildSignal("fat-b"); + var c = BuildSignal("fat-c"); + a.RemoveFromFat(); + b.RemoveFromFat(); + c.RemoveFromFat(); + var project = new FatVerificationProject + { + Signals = new List { a, b, c } + }; + + var restored = project.RestoreSignals(new[] { a.SignalId, c.SignalId }); + + Assert.Equal(2, restored); + Assert.True(a.IsIncludedInFat); + Assert.False(b.IsIncludedInFat); + Assert.True(c.IsIncludedInFat); + Assert.Single(project.RemovedSignals); + Assert.Same(b, project.RemovedSignals[0]); + } + + [Fact] + public void ReplacingCurrentEvidence_DoesNotChangeFatInclusion() + { + var signal = BuildSignal("fat-rolling"); + signal.RemoveFromFat(); + var first = Evidence(FatValueSlot.Value1, "100.0 A"); + var replacement = Evidence(FatValueSlot.Value1, "101.0 A"); + + signal.SetCurrentEvidence(first); + signal.SetCurrentEvidence(replacement); + + Assert.False(signal.IsIncludedInFat); + Assert.Same(replacement, signal.Value1Evidence); + } + + private static FatVerificationSignal BuildSignal(string id) + => new() + { + SignalId = id, + IedName = "IED1", + DataSetReference = "IED1LD0/LLN0$DS", + DataSetMemberIndex = 0, + StaticMemberReference = "IED1MEAS/MMXU1.A.phsA", + RuntimeReference = "IED1MEAS/MMXU1.A.phsA.cVal.mag.f", + SignalName = "A.phsA", + FunctionalConstraint = "MX", + DataType = "FLOAT32", + SignalKind = FatSignalKind.Analog, + CaptureMode = FatCaptureMode.OperatorSnapshot + }; + + private static FatValueEvidence Evidence(FatValueSlot slot, string rawValue) + => new( + Guid.NewGuid(), + slot, + FatEvidenceCaptureKind.OperatorSnapshot, + rawValue, + new DateTimeOffset(2026, 8, 31, 10, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 8, 31, 9, 59, 59, 900, TimeSpan.Zero), + "good", + "MMS", + 1, + 1); +}