diff --git a/IoListTestingWindow.ContextUx.cs b/IoListTestingWindow.ContextUx.cs index d4f0c2e3a..1bba89d42 100644 --- a/IoListTestingWindow.ContextUx.cs +++ b/IoListTestingWindow.ContextUx.cs @@ -99,9 +99,11 @@ public string SelectedProgressText } } - public int SelectedEvidenceCount => SelectedIed?.TestPoints.Sum(point => - (point.Runtime.OnEvidence == null ? 0 : 1) + - (point.Runtime.OffEvidence == null ? 0 : 1)) ?? 0; + public int SelectedEvidenceCount => + (SelectedIed?.TestPoints.Sum(point => + (point.Runtime.OnEvidence == null ? 0 : 1) + + (point.Runtime.OffEvidence == null ? 0 : 1)) ?? 0) + + SelectedSupplementalEvidenceCount; private bool IsSelectedSessionIed => SelectedIed != null && ReferenceEquals(Session.ActiveIed, SelectedIed); @@ -109,7 +111,12 @@ public string SelectedProgressText private void IoListTestingWindow_ContentRendered(object? sender, EventArgs e) { Dispatcher.BeginInvoke( - new Action(InstallSelectedIedContext), + new Action(() => + { + InstallSelectedIedContext(); + InstallSupplementalEvidenceControls(); + RefreshSupplementalEvidenceControls(); + }), DispatcherPriority.ContextIdle); } @@ -230,6 +237,8 @@ private async void StartSelectedIedSafely_Click(object sender, RoutedEventArgs e ShowActionResult(preparation, "IED acquisition could not start"); return; } + + await CaptureTimeSyncEvidenceAfterPreparationAsync(engineeringWindow, selectedIed); } var result = Session.Start(selectedIed); @@ -296,7 +305,9 @@ private void RaiseSelectedIedContextProperties() Raise(nameof(SelectedStartWorkflowText)); Raise(nameof(SelectedFooterStatusText)); Raise(nameof(SelectedProgressText)); + Raise(nameof(SelectedSupplementalEvidenceCount)); Raise(nameof(SelectedEvidenceCount)); + RefreshSupplementalEvidenceControls(); } private void ContextWindow_Closed(object? sender, EventArgs e) diff --git a/IoListTestingWindow.SupplementalEvidence.cs b/IoListTestingWindow.SupplementalEvidence.cs new file mode 100644 index 000000000..689b3adee --- /dev/null +++ b/IoListTestingWindow.SupplementalEvidence.cs @@ -0,0 +1,228 @@ +using System.Windows; +using System.Windows.Controls; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ArIED61850Tester; + +public partial class IoListTestingWindow +{ + private Button? _comtradeEvidenceButton; + private Button? _timeSyncEvidenceButton; + + public int SelectedSupplementalEvidenceCount + { + get + { + if (SelectedIed == null) + return 0; + + var comtrade = IoFatSupplementalEvidenceService.Count( + Storage, + SelectedIed.IedName, + IoFatSupplementalEvidenceService.ComtradeKind); + var timeSync = IoFatSupplementalEvidenceService.ReadLatest( + Storage, + SelectedIed.IedName, + IoFatSupplementalEvidenceService.TimeSyncKind) == null ? 0 : 1; + return comtrade + timeSync; + } + } + + private void InstallSupplementalEvidenceControls() + { + if (_comtradeEvidenceButton != null || WorkspacePreviewToggle.Parent is not Panel actionPanel) + return; + + _timeSyncEvidenceButton = CreateEvidenceButton("Time Sync · —", RefreshTimeSyncEvidence_Click); + _timeSyncEvidenceButton.ToolTip = "Capture IEC 61850 time-synchronization evidence for the selected IED"; + + _comtradeEvidenceButton = CreateEvidenceButton("COMTRADE · —", OpenComtradeEvidence_Click); + _comtradeEvidenceButton.ToolTip = "Browse relay fault records. A remote COMTRADE listing is sufficient File Service FAT evidence; download is optional."; + + var insertionIndex = actionPanel.Children.IndexOf(WorkspacePreviewToggle) + 1; + actionPanel.Children.Insert(insertionIndex, _timeSyncEvidenceButton); + actionPanel.Children.Insert(insertionIndex + 1, _comtradeEvidenceButton); + RefreshSupplementalEvidenceControls(); + } + + private Button CreateEvidenceButton(string content, RoutedEventHandler handler) + { + var button = new Button + { + Content = content, + Padding = new Thickness(10, 8, 10, 8), + Margin = new Thickness(0, 0, 6, 0), + MinWidth = 92 + }; + if (TryFindResource("SoftButton") is Style style) + button.Style = style; + button.Click += handler; + return button; + } + + private void RefreshSupplementalEvidenceControls() + { + if (_timeSyncEvidenceButton == null || _comtradeEvidenceButton == null) + return; + + var ied = SelectedIed; + if (ied == null) + { + _timeSyncEvidenceButton.Content = "Time Sync · —"; + _timeSyncEvidenceButton.ToolTip = "Select an IED first"; + _comtradeEvidenceButton.Content = "COMTRADE · —"; + _comtradeEvidenceButton.ToolTip = "Select an IED first"; + return; + } + + var timeSync = IoFatSupplementalEvidenceService.ReadLatest( + Storage, + ied.IedName, + IoFatSupplementalEvidenceService.TimeSyncKind); + _timeSyncEvidenceButton.Content = timeSync == null + ? "Time Sync · —" + : $"Time Sync · {timeSync.Verdict}"; + _timeSyncEvidenceButton.ToolTip = timeSync == null + ? "No time-sync evidence captured yet. It will be captured automatically after FAT connection." + : $"{timeSync.DisplayText}\n{timeSync.Reason}\nCaptured {timeSync.RecordedAtUtc.ToLocalTime():yyyy-MM-dd HH:mm:ss}"; + + var comtradeCount = IoFatSupplementalEvidenceService.Count( + Storage, + ied.IedName, + IoFatSupplementalEvidenceService.ComtradeKind); + _comtradeEvidenceButton.Content = ied.HasRemoteComtradeEvidence + ? $"COMTRADE · PASS · {comtradeCount}" + : $"COMTRADE · {comtradeCount}"; + _comtradeEvidenceButton.ToolTip = ied.HasRemoteComtradeEvidence + ? $"File Service PASS via IEC 61850 FileDirectory\nLatest remote COMTRADE: {ied.LatestComtradeFiles}\nRemote path: {ied.LatestComtradeRemotePath}\nDownload is optional additional verification." + : "Open relay fault records. A supported remote COMTRADE returned by IEC 61850 FileDirectory becomes IED-level FAT evidence immediately; download is optional."; + } + + private async Task CaptureTimeSyncEvidenceAfterPreparationAsync( + MainWindow engineeringWindow, + IoTestIedPlan ied) + { + var device = FindFatDevice(engineeringWindow, ied); + if (device == null) + return; + + // Give the extra read-only sync signal a short bounded window to receive its + // first report/poll value after the FAT monitor starts. Do not block the FAT + // workflow when a relay does not expose a recognized status object. + var syncSignal = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device); + if (syncSignal != null) + { + for (var attempt = 0; attempt < 8 && !HasLiveValue(syncSignal.Value); attempt++) + await Task.Delay(175).ConfigureAwait(true); + } + + IoFatSupplementalEvidenceService.CaptureTimeSync(Storage, Project, ied, device); + Storage?.ScheduleSave(); + RefreshSupplementalEvidenceControls(); + Raise(nameof(SelectedSupplementalEvidenceCount)); + Raise(nameof(SelectedEvidenceCount)); + } + + private async void RefreshTimeSyncEvidence_Click(object sender, RoutedEventArgs e) + { + if (SelectedIed == null) + return; + + if (Owner is not MainWindow engineeringWindow) + { + MessageBox.Show( + this, + "Open this FAT workspace from the ARSAS engineering window to refresh live time-sync evidence.", + "Time Sync Evidence", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var device = FindFatDevice(engineeringWindow, SelectedIed); + if (device == null || !device.IsConnected) + { + MessageBox.Show( + this, + "Connect the selected IED first. ARSAS will then capture its explicit synchronization status or timestamp fallback.", + "Time Sync Evidence", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + await CaptureTimeSyncEvidenceAfterPreparationAsync(engineeringWindow, SelectedIed); + } + + private void OpenComtradeEvidence_Click(object sender, RoutedEventArgs e) + { + var ied = SelectedIed; + if (ied == null) + return; + + var window = new FaultRecordWindow(ied.IedName, ied.IpAddress, 102) + { + Owner = this + }; + window.ShowDialog(); + + // A successful FileDirectory browse that returns a supported fault record is the + // primary FAT evidence. Download is intentionally not a prerequisite. + var remoteEvidence = IoFatRemoteComtradeEvidenceService.CaptureLatest( + Storage, + Project, + ied, + window.Records.Select(row => row.Record)); + + // Preserve stronger optional local-artifact evidence when the operator also + // downloaded a record. A failed or skipped download never removes remote PASS. + var downloadedCaptured = 0; + foreach (var row in window.Records.Where(row => + row.LocalState == FaultRecordLocalState.Downloaded && + !string.IsNullOrWhiteSpace(row.LocalDirectory))) + { + var evidence = IoFatSupplementalEvidenceService.CaptureComtrade( + Storage, + Project, + ied, + row.RecordName, + row.LocalDirectory); + if (evidence != null) + downloadedCaptured++; + } + + if (remoteEvidence != null) + { + PreparationStatusText = + $"{ied.IedName} · File Service PASS · latest COMTRADE {ied.LatestComtradeFiles} · download optional."; + Storage?.ScheduleSave(); + } + else if (downloadedCaptured > 0) + { + PreparationStatusText = $"{ied.IedName} · downloaded COMTRADE/fault-record evidence journaled."; + Storage?.ScheduleSave(); + } + + RefreshSupplementalEvidenceControls(); + Raise(nameof(SelectedSupplementalEvidenceCount)); + Raise(nameof(SelectedEvidenceCount)); + } + + private static Iec61850MonitorDevice? FindFatDevice(MainWindow engineeringWindow, IoTestIedPlan ied) + => engineeringWindow.Devices.FirstOrDefault(device => + (device.Name.Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) || + device.SclIedName.Equals(ied.IedName, StringComparison.OrdinalIgnoreCase)) && + device.IpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)) + ?? engineeringWindow.Devices.FirstOrDefault(device => + device.IpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)); + + private static bool HasLiveValue(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && text != "—" && + !text.Equals("Unknown", StringComparison.OrdinalIgnoreCase) && + !text.Contains("not probed", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index ba6e2d78f..30d7681cf 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -168,6 +168,15 @@ void ReportProgress(string message) match.Signal.IsSelected = true; selectionChanged = true; } + + // Time synchronization is device-level FAT evidence rather than an ON/OFF + // test point. If the live model exposes an explicit status (for example + // SIPROTEC TimeSynchrnz or MiCOM LLN0.SyncSt), arm that one extra read-only + // signal so the FAT window can capture the real IED value automatically. + var timeSyncArmed = IoFatSupplementalEvidenceService.EnsureTimeSyncSignalSelected(device); + if (timeSyncArmed) + selectionChanged = true; + device.RecountSelectedSignals(); device.RefreshComputed(); RaiseWorkspaceCounts(); @@ -218,7 +227,10 @@ void ReportProgress(string message) var acquisitionText = acquisition.PollingCount == 0 ? $"report-backed {acquisition.ReportCount}/{requestedPoints.Count}" : $"report-backed {acquisition.ReportCount}/{requestedPoints.Count} · MMS fallback {acquisition.PollingCount}"; - var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}"; + var timeSyncText = IoFatSupplementalEvidenceService.FindTimeSyncSignal(device) == null + ? " · time-sync fallback ready" + : " · time-sync status armed"; + var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {acquisitionText}{timeSyncText}"; SetStatus(message); AddLog( acquisition.PollingCount == 0 ? "INFO" : "WARN", diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index cfff64c4d..34961c137 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -313,6 +313,21 @@ public sealed class IoTestIedPlan : ObservableObject public string Switchgear { get; init; } = string.Empty; public List TestPoints { get; init; } = new(); + // Persisted IED-level file-service evidence. A successful IEC 61850 FileDirectory + // listing is sufficient FAT evidence that the remote file service is accessible; + // FileOpen/FileRead download remains optional deeper verification. + public string LatestComtradeFiles { get; set; } = string.Empty; + public string LatestComtradeRemotePath { get; set; } = string.Empty; + public string LatestComtradeCompleteness { get; set; } = string.Empty; + public string LatestComtradeAcquisitionSource { get; set; } = string.Empty; + public DateTimeOffset? LatestComtradeModifiedAtUtc { get; set; } + public DateTimeOffset? LatestComtradeCapturedAtUtc { get; set; } + public int LatestComtradeFileCount { get; set; } + public long LatestComtradeKnownSizeBytes { get; set; } + + [JsonIgnore] + public bool HasRemoteComtradeEvidence => !string.IsNullOrWhiteSpace(LatestComtradeFiles); + public string LiveDeviceId { get => _liveDeviceId; private set => Set(ref _liveDeviceId, value ?? string.Empty); } public string LiveStatusText { get => _liveStatusText; private set => Set(ref _liveStatusText, value ?? string.Empty); } @@ -460,4 +475,4 @@ public void InitializeRuntimeNotifications() foreach (var ied in Ieds) ied.InitializeRuntimeNotifications(); } -} +} \ No newline at end of file diff --git a/Models/IoTesting/IoTestSessionModels.cs b/Models/IoTesting/IoTestSessionModels.cs index b9fcbdf14..f9b04ca1a 100644 --- a/Models/IoTesting/IoTestSessionModels.cs +++ b/Models/IoTesting/IoTestSessionModels.cs @@ -48,6 +48,15 @@ public sealed record IoTestJournalEntry public long ConnectionGeneration { get; init; } public string Verdict { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty; + + // Supplemental FAT evidence (COMTRADE, time synchronization, and future + // device-level evidence) lives in the same hash-chained journal format as signal + // transitions. Empty defaults preserve backward compatibility with older journals. + public string EvidenceKind { get; init; } = string.Empty; + public string ArtifactName { get; init; } = string.Empty; + public string ArtifactPath { get; init; } = string.Empty; + public string ArtifactSha256 { get; init; } = string.Empty; + public long ArtifactBytes { get; init; } } public sealed record IoTestJournalEnvelope( diff --git a/Services/IoTesting/IoFatPdfReportService.cs b/Services/IoTesting/IoFatPdfReportService.cs index 8e3473ab9..ddd5b61a9 100644 --- a/Services/IoTesting/IoFatPdfReportService.cs +++ b/Services/IoTesting/IoFatPdfReportService.cs @@ -24,7 +24,8 @@ internal static IoFatReportLayoutPlan BuildLayout( bool draft = false) { ArgumentNullException.ThrowIfNull(project); - return IoFatExecutiveReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + var layout = IoFatExecutiveReportLayoutEngine.Build(project, generatedAt ?? DateTimeOffset.Now, draft); + return IoFatSupplementalReportLayoutDecorator.AppendFileServiceEvidence(project, layout); } public static void Save(string fileName, IoTestProject project, DateTimeOffset? generatedAt = null) @@ -55,4 +56,4 @@ public static void Save(string fileName, IoTestProject project, DateTimeOffset? File.Delete(temporary); } } -} +} \ No newline at end of file diff --git a/Services/IoTesting/IoFatRemoteComtradeEvidenceService.cs b/Services/IoTesting/IoFatRemoteComtradeEvidenceService.cs new file mode 100644 index 000000000..69a85a595 --- /dev/null +++ b/Services/IoTesting/IoFatRemoteComtradeEvidenceService.cs @@ -0,0 +1,134 @@ +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Converts a successful IEC 61850 FileDirectory fault-record listing into durable +/// IED-level FAT evidence. Download is deliberately not required: discovery proves +/// that the relay file service is reachable and exposes a supported fault record. +/// +public static class IoFatRemoteComtradeEvidenceService +{ + public const string EventType = "COMTRADE_REMOTE_EVIDENCE"; + public const string AcquisitionSource = "IEC 61850 FileDirectory"; + + public static IoFatSupplementalEvidenceSummary? CaptureLatest( + IoTestWorkspacePersistence? storage, + IoTestProject project, + IoTestIedPlan ied, + IEnumerable records) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + ArgumentNullException.ThrowIfNull(records); + + var latest = records + .Where(record => record.Files.Count > 0) + .OrderByDescending(record => record.LastModifiedUtc ?? DateTimeOffset.MinValue) + .ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + if (latest == null) + return null; + + var orderedFiles = latest.Files + .OrderBy(FilePriority) + .ThenBy(file => file.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var fileNames = string.Join(" + ", orderedFiles.Select(file => file.Name)); + var primaryPath = orderedFiles.FirstOrDefault()?.RemotePath ?? latest.RecordId; + var capturedAt = DateTimeOffset.UtcNow; + + var alreadyCaptured = + ied.HasRemoteComtradeEvidence && + ied.LatestComtradeFiles.Equals(fileNames, StringComparison.OrdinalIgnoreCase) && + ied.LatestComtradeRemotePath.Equals(primaryPath, StringComparison.OrdinalIgnoreCase) && + Nullable.Equals(ied.LatestComtradeModifiedAtUtc, latest.LastModifiedUtc) && + ied.LatestComtradeCompleteness.Equals(latest.Completeness, StringComparison.OrdinalIgnoreCase); + + ied.LatestComtradeFiles = fileNames; + ied.LatestComtradeRemotePath = primaryPath; + ied.LatestComtradeCompleteness = latest.Completeness; + ied.LatestComtradeAcquisitionSource = AcquisitionSource; + ied.LatestComtradeModifiedAtUtc = latest.LastModifiedUtc; + ied.LatestComtradeCapturedAtUtc = capturedAt; + ied.LatestComtradeFileCount = orderedFiles.Length; + ied.LatestComtradeKnownSizeBytes = latest.KnownSizeBytes; + + var reason = + "IED returned a supported COMTRADE/fault-record entry through IEC 61850 FileDirectory. " + + "This is FAT evidence that the remote file-service browse path is available. " + + "FileOpen/FileRead download is optional additional verification and is not claimed by this evidence."; + + var summary = new IoFatSupplementalEvidenceSummary( + IoFatSupplementalEvidenceService.ComtradeKind, + ied.IedName, + capturedAt, + "PASS", + $"PASS · latest remote COMTRADE · {fileNames}", + latest.RecordId, + $"Remote COMTRADE listed: {fileNames}", + "Listed", + AcquisitionSource, + fileNames, + primaryPath, + string.Empty, + latest.KnownSizeBytes, + reason); + + if (storage == null || alreadyCaptured) + return summary; + + var entry = new IoTestJournalEntry + { + EventType = EventType, + RecordedAtUtc = capturedAt, + ProjectId = project.ProjectId, + SessionId = Guid.NewGuid(), + IedName = ied.IedName, + IpAddress = ied.IpAddress, + SourceWorkbookName = project.SourceWorkbookName, + SourceWorkbookSha256 = project.SourceWorkbookSha256, + ApplicationVersion = typeof(IoFatRemoteComtradeEvidenceService).Assembly.GetName().Version?.ToString() ?? string.Empty, + Operator = Environment.UserName, + Workstation = Environment.MachineName, + ObjectReference = latest.RecordId, + ObservedValue = $"Remote COMTRADE listed: {fileNames}", + IedTimestamp = latest.LastModifiedUtc, + Quality = "Listed", + AcquisitionSource = AcquisitionSource, + Verdict = "PASS", + Reason = reason, + EvidenceKind = IoFatSupplementalEvidenceService.ComtradeKind, + ArtifactName = fileNames, + ArtifactPath = primaryPath, + ArtifactBytes = latest.KnownSizeBytes + }; + + var evidenceRoot = Directory.GetParent(storage.EvidenceProjectDirectory)?.FullName + ?? storage.EvidenceProjectDirectory; + using var journal = IoTestEvidenceJournal.Create( + evidenceRoot, + project, + ied, + entry.SessionId, + entry.RecordedAtUtc); + journal.Append(entry); + return summary; + } + + private static int FilePriority(Iec61850FaultRecordFile file) + { + var extension = (file.Extension ?? string.Empty).Trim().ToLowerInvariant(); + return extension switch + { + ".cfg" => 0, + ".dat" => 1, + ".cff" => 2, + ".hdr" => 3, + ".inf" => 4, + ".zip" => 5, + _ => 10 + }; + } +} \ No newline at end of file diff --git a/Services/IoTesting/IoFatStringCompatibilityExtensions.cs b/Services/IoTesting/IoFatStringCompatibilityExtensions.cs new file mode 100644 index 000000000..1824a5cc5 --- /dev/null +++ b/Services/IoTesting/IoFatStringCompatibilityExtensions.cs @@ -0,0 +1,11 @@ +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Keeps FAT file-name checks explicit about comparison semantics when the BCL only +/// exposes the char StartsWith overload without StringComparison. +/// +internal static class IoFatStringCompatibilityExtensions +{ + public static bool StartsWith(this string value, char prefix, StringComparison comparison) + => value.StartsWith(prefix.ToString(), comparison); +} diff --git a/Services/IoTesting/IoFatSupplementalEvidenceService.cs b/Services/IoTesting/IoFatSupplementalEvidenceService.cs new file mode 100644 index 000000000..5470b61b6 --- /dev/null +++ b/Services/IoTesting/IoFatSupplementalEvidenceService.cs @@ -0,0 +1,502 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +public sealed record IoFatSupplementalEvidenceSummary( + string Kind, + string IedName, + DateTimeOffset RecordedAtUtc, + string Verdict, + string DisplayText, + string ObjectReference, + string ObservedValue, + string Quality, + string AcquisitionSource, + string ArtifactName, + string ArtifactPath, + string ArtifactSha256, + long ArtifactBytes, + string Reason) +{ + public bool Exists => !string.IsNullOrWhiteSpace(Kind); +} + +/// +/// Device-level FAT evidence that is independent from individual ON/OFF test rows. +/// Entries use the existing tamper-evident JSONL journal so a COMTRADE or time-sync +/// result remains attached to the IED even when panel/test-point scope changes later. +/// +public static class IoFatSupplementalEvidenceService +{ + public const string ComtradeKind = "COMTRADE"; + public const string TimeSyncKind = "TIME_SYNC"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public static SignalDefinition? FindTimeSyncSignal(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + return device.Signals + .Where(signal => !signal.IsControlSignal && !string.IsNullOrWhiteSpace(signal.ObjectReference)) + .Select(signal => new { Signal = signal, Score = TimeSyncScore(signal) }) + .Where(item => item.Score > 0) + .OrderByDescending(item => item.Score) + .ThenBy(item => item.Signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .Select(item => item.Signal) + .FirstOrDefault(); + } + + public static bool EnsureTimeSyncSignalSelected(Iec61850MonitorDevice device) + { + var signal = FindTimeSyncSignal(device); + if (signal == null || signal.IsSelected) + return false; + + signal.IsSelected = true; + return true; + } + + public static IoFatSupplementalEvidenceSummary? CaptureTimeSync( + IoTestWorkspacePersistence? storage, + IoTestProject project, + IoTestIedPlan ied, + Iec61850MonitorDevice device) + { + if (storage == null) + return null; + + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + ArgumentNullException.ThrowIfNull(device); + + var explicitSignal = FindTimeSyncSignal(device); + IoTestJournalEntry entry; + if (explicitSignal != null) + { + var value = (explicitSignal.Value ?? string.Empty).Trim(); + var normalized = NormalizeBoolean(value); + var verdict = normalized switch + { + true => "PASS", + false => "FAIL", + _ => IsUsableValue(value) ? "DETECTED" : "PENDING" + }; + var reason = normalized switch + { + true => "IED exposes an explicit IEC 61850 synchronization status and reports synchronized.", + false => "IED exposes an explicit IEC 61850 synchronization status and reports not synchronized.", + _ when IsUsableValue(value) => "IED exposes an explicit synchronization object; preserve the raw vendor value for FAT evidence.", + _ => "Synchronization object was discovered and armed for monitoring, but a live value has not arrived yet." + }; + + entry = BaseEntry(project, ied, "TIME_SYNC_EVIDENCE", TimeSyncKind) with + { + SignalName = string.IsNullOrWhiteSpace(explicitSignal.Name) ? "Time synchronization" : explicitSignal.Name, + ObjectReference = explicitSignal.ObjectReference, + ObservedValue = IsUsableValue(value) ? value : "No live value yet", + NormalizedState = normalized, + IedTimestamp = TryParseTimestamp(explicitSignal.DeviceTimestamp), + Quality = explicitSignal.Quality, + AcquisitionSource = string.IsNullOrWhiteSpace(explicitSignal.ReportPlan) ? "IEC 61850" : explicitSignal.ReportPlan, + Verdict = verdict, + Reason = reason + }; + } + else + { + var timestampPoint = device.Points + .Select(point => new { Point = point, Parsed = TryParseTimestamp(point.DeviceTimestamp) }) + .Where(item => item.Parsed.HasValue) + .OrderByDescending(item => item.Parsed) + .FirstOrDefault(); + + if (timestampPoint == null) + { + entry = BaseEntry(project, ied, "TIME_SYNC_EVIDENCE", TimeSyncKind) with + { + ObservedValue = "No explicit sync object or parseable IED timestamp", + Verdict = "REVIEW", + Reason = "This IED model does not expose a recognized synchronization status. Capture relay clock evidence manually or verify the configured time source." + }; + } + else + { + var delta = (DateTimeOffset.UtcNow - timestampPoint.Parsed!.Value.ToUniversalTime()).Duration(); + entry = BaseEntry(project, ied, "TIME_SYNC_EVIDENCE", TimeSyncKind) with + { + SignalName = timestampPoint.Point.SignalName, + ObjectReference = timestampPoint.Point.IecReference, + ObservedValue = $"IED timestamp observed; UTC delta {delta.TotalSeconds:F3} s", + IedTimestamp = timestampPoint.Parsed, + Quality = timestampPoint.Point.Quality, + AcquisitionSource = timestampPoint.Point.SourceMode, + Verdict = "OBSERVED", + Reason = "No explicit sync-status object was found; the freshest live IED timestamp is preserved as fallback evidence without claiming synchronization state." + }; + } + } + + var previous = ReadLatest(storage, ied.IedName, TimeSyncKind); + if (previous != null && EquivalentTimeSync(previous, entry)) + return previous; + + Append(storage, project, ied, entry); + return ToSummary(entry); + } + + public static IoFatSupplementalEvidenceSummary? CaptureComtrade( + IoTestWorkspacePersistence? storage, + IoTestProject project, + IoTestIedPlan ied, + string recordName, + string localDirectory) + { + if (storage == null || string.IsNullOrWhiteSpace(localDirectory) || !Directory.Exists(localDirectory)) + return null; + + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(ied); + + string[] files; + try + { + files = Directory.EnumerateFiles(localDirectory, "*", SearchOption.TopDirectoryOnly) + .Where(path => !Path.GetFileName(path).StartsWith('.', StringComparison.Ordinal)) + .OrderBy(path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return null; + } + + if (files.Length == 0) + return null; + + var extensions = files + .Select(path => Path.GetExtension(path).ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var hasCfg = extensions.Contains(".cfg"); + var hasData = extensions.Contains(".dat") || extensions.Contains(".data"); + var hasPackage = extensions.Contains(".zip") || extensions.Contains(".cff"); + var completePair = hasCfg && hasData; + var verdict = completePair ? "PASS" : hasPackage ? "DETECTED" : "REVIEW"; + var bytes = files.Sum(SafeLength); + var manifestHash = HashManifest(files); + var displayName = string.IsNullOrWhiteSpace(recordName) + ? Path.GetFileName(localDirectory) + : recordName.Trim(); + + var entry = BaseEntry(project, ied, "COMTRADE_EVIDENCE", ComtradeKind) with + { + SignalName = "Fault record", + ObservedValue = completePair + ? $"COMTRADE CFG + DAT detected ({files.Length} file(s))" + : $"Fault-record files detected ({files.Length} file(s))", + AcquisitionSource = "IEC 61850 file services", + Verdict = verdict, + Reason = completePair + ? "Downloaded local files contain a COMTRADE configuration/data pair and are registered as IED-level FAT evidence." + : "Downloaded fault-record files are registered as FAT evidence, but a classic CFG + DAT pair was not both present; preserve the vendor package for review.", + ArtifactName = displayName, + ArtifactPath = Path.GetFullPath(localDirectory), + ArtifactSha256 = manifestHash, + ArtifactBytes = bytes + }; + + var existing = ReadLatest(storage, ied.IedName, ComtradeKind, manifestHash); + if (existing != null) + return existing; + + Append(storage, project, ied, entry); + return ToSummary(entry); + } + + public static IoFatSupplementalEvidenceSummary? ReadLatest( + IoTestWorkspacePersistence? storage, + string iedName, + string kind, + string? artifactSha256 = null) + { + if (storage == null || string.IsNullOrWhiteSpace(iedName) || string.IsNullOrWhiteSpace(kind) || + !Directory.Exists(storage.EvidenceProjectDirectory)) + { + return null; + } + + IoTestJournalEntry? latest = null; + foreach (var path in SafeEvidenceFiles(storage.EvidenceProjectDirectory)) + { + foreach (var entry in ReadEntries(path)) + { + if (!entry.IedName.Equals(iedName, StringComparison.OrdinalIgnoreCase) || + !entry.EvidenceKind.Equals(kind, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(artifactSha256) && + !entry.ArtifactSha256.Equals(artifactSha256, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (latest == null || entry.RecordedAtUtc > latest.RecordedAtUtc) + latest = entry; + } + } + + return latest == null ? null : ToSummary(latest); + } + + public static int Count( + IoTestWorkspacePersistence? storage, + string iedName, + string kind) + { + if (storage == null || !Directory.Exists(storage.EvidenceProjectDirectory)) + return 0; + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in SafeEvidenceFiles(storage.EvidenceProjectDirectory)) + { + foreach (var entry in ReadEntries(path)) + { + if (!entry.IedName.Equals(iedName, StringComparison.OrdinalIgnoreCase) || + !entry.EvidenceKind.Equals(kind, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var key = string.IsNullOrWhiteSpace(entry.ArtifactSha256) + ? $"{entry.EventType}|{entry.ObjectReference}|{entry.ObservedValue}|{entry.Verdict}" + : entry.ArtifactSha256; + keys.Add(key); + } + } + return keys.Count; + } + + private static void Append( + IoTestWorkspacePersistence storage, + IoTestProject project, + IoTestIedPlan ied, + IoTestJournalEntry entry) + { + var evidenceRoot = Directory.GetParent(storage.EvidenceProjectDirectory)?.FullName + ?? storage.EvidenceProjectDirectory; + using var journal = IoTestEvidenceJournal.Create( + evidenceRoot, + project, + ied, + entry.SessionId, + entry.RecordedAtUtc); + journal.Append(entry); + } + + private static IReadOnlyList SafeEvidenceFiles(string directory) + { + try + { + return Directory.EnumerateFiles(directory, "*.evidence.jsonl", SearchOption.TopDirectoryOnly).ToArray(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return Array.Empty(); + } + } + + private static IReadOnlyList ReadEntries(string path) + { + var entries = new List(); + IoTestJournalVerificationResult verification; + try + { + verification = IoTestEvidenceJournal.Verify(path); + } + catch + { + return entries; + } + + if (!verification.IsValid) + return entries; + + string[] lines; + try + { + lines = File.ReadAllLines(path, Encoding.UTF8); + } + catch + { + return entries; + } + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + try + { + var envelope = JsonSerializer.Deserialize(line, JsonOptions); + if (envelope?.Entry != null) + entries.Add(envelope.Entry); + } + catch (JsonException) + { + // Verification already protects the chain. Ignore a line here only so a + // damaged supplemental display cannot crash the FAT workspace. + } + } + + return entries; + } + + private static IoTestJournalEntry BaseEntry( + IoTestProject project, + IoTestIedPlan ied, + string eventType, + string evidenceKind) + => new() + { + EventType = eventType, + RecordedAtUtc = DateTimeOffset.UtcNow, + ProjectId = project.ProjectId, + SessionId = Guid.NewGuid(), + IedName = ied.IedName, + IpAddress = ied.IpAddress, + SourceWorkbookName = project.SourceWorkbookName, + SourceWorkbookSha256 = project.SourceWorkbookSha256, + ApplicationVersion = typeof(IoFatSupplementalEvidenceService).Assembly.GetName().Version?.ToString() ?? string.Empty, + Operator = Environment.UserName, + Workstation = Environment.MachineName, + EvidenceKind = evidenceKind + }; + + private static IoFatSupplementalEvidenceSummary ToSummary(IoTestJournalEntry entry) + { + var detail = entry.EvidenceKind.Equals(ComtradeKind, StringComparison.OrdinalIgnoreCase) + ? $"{entry.Verdict} · {entry.ArtifactName} · {FormatBytes(entry.ArtifactBytes)}" + : string.IsNullOrWhiteSpace(entry.ObjectReference) + ? $"{entry.Verdict} · {entry.ObservedValue}" + : $"{entry.Verdict} · {entry.ObservedValue} · {entry.ObjectReference}"; + + return new IoFatSupplementalEvidenceSummary( + entry.EvidenceKind, + entry.IedName, + entry.RecordedAtUtc, + entry.Verdict, + detail, + entry.ObjectReference, + entry.ObservedValue, + entry.Quality, + entry.AcquisitionSource, + entry.ArtifactName, + entry.ArtifactPath, + entry.ArtifactSha256, + entry.ArtifactBytes, + entry.Reason); + } + + private static bool EquivalentTimeSync( + IoFatSupplementalEvidenceSummary previous, + IoTestJournalEntry current) + => previous.ObjectReference.Equals(current.ObjectReference, StringComparison.OrdinalIgnoreCase) && + previous.ObservedValue.Equals(current.ObservedValue, StringComparison.OrdinalIgnoreCase) && + previous.Verdict.Equals(current.Verdict, StringComparison.OrdinalIgnoreCase) && + previous.Quality.Equals(current.Quality, StringComparison.OrdinalIgnoreCase); + + private static int TimeSyncScore(SignalDefinition signal) + { + var reference = (signal.ObjectReference ?? string.Empty).Replace('$', '.'); + var name = signal.Name ?? string.Empty; + var text = $"{reference} {name}"; + if (reference.Contains("SyncSt.stVal", StringComparison.OrdinalIgnoreCase)) return 100; + if (reference.Contains("TimeSynchrnz", StringComparison.OrdinalIgnoreCase)) return 100; + if (reference.Contains("TimeSync", StringComparison.OrdinalIgnoreCase)) return 95; + if (text.Contains("clock sync", StringComparison.OrdinalIgnoreCase)) return 90; + if (text.Contains("time sync", StringComparison.OrdinalIgnoreCase)) return 90; + if (text.Contains("synchroniz", StringComparison.OrdinalIgnoreCase)) return 80; + return 0; + } + + private static bool? NormalizeBoolean(string value) + { + if (!IsUsableValue(value)) + return null; + + var normalized = value.Trim().ToLowerInvariant(); + if (normalized is "true" or "1" or "on" or "active" or "synchronized" or "synchronised" or "synced" or "ok") + return true; + if (normalized is "false" or "0" or "off" or "inactive" or "not synchronized" or "not synchronised" or "unsynchronized" or "unsynchronised") + return false; + return null; + } + + private static bool IsUsableValue(string? value) + { + var text = (value ?? string.Empty).Trim(); + return text.Length > 0 && text != "-" && text != "—" && + !text.Equals("unknown", StringComparison.OrdinalIgnoreCase) && + !text.Contains("not probed", StringComparison.OrdinalIgnoreCase); + } + + private static DateTimeOffset? TryParseTimestamp(string? value) + { + var text = (value ?? string.Empty).Trim(); + if (!IsUsableValue(text)) + return null; + + return DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeUniversal, + out var parsed) + ? parsed + : null; + } + + private static string HashManifest(IEnumerable files) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (var path in files.OrderBy(path => Path.GetFileName(path), StringComparer.OrdinalIgnoreCase)) + { + hash.AppendData(Encoding.UTF8.GetBytes(Path.GetFileName(path).ToLowerInvariant())); + using var stream = File.OpenRead(path); + hash.AppendData(SHA256.HashData(stream)); + hash.AppendData(BitConverter.GetBytes(SafeLength(path))); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static long SafeLength(string path) + { + try { return new FileInfo(path).Length; } + catch { return 0; } + } + + private static string FormatBytes(long bytes) + { + string[] units = ["B", "KB", "MB", "GB"]; + var value = Math.Max(0, bytes); + var display = (double)value; + var unit = 0; + while (display >= 1024d && unit < units.Length - 1) + { + display /= 1024d; + unit++; + } + return unit == 0 ? $"{value:N0} {units[unit]}" : $"{display:N1} {units[unit]}"; + } +} diff --git a/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs b/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs new file mode 100644 index 000000000..8cef4207d --- /dev/null +++ b/Services/IoTesting/IoFatSupplementalReportLayoutDecorator.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Appends controlled IED-level file-service evidence to the same layout consumed by +/// native PDF output and WPF print preview. Existing signal pages are retained and their +/// page totals are corrected after supplemental pages are added. +/// +internal static class IoFatSupplementalReportLayoutDecorator +{ + private const double PageWidth = 842d; + private const double PageHeight = 595d; + private const double Margin = 30d; + private const double ContentWidth = PageWidth - (Margin * 2d); + private const int RowsPerPage = 6; + + private static readonly IoFatReportColor Navy = Color("0F172A"); + private static readonly IoFatReportColor Blue = Color("2563EB"); + private static readonly IoFatReportColor SoftBlue = Color("EFF6FF"); + private static readonly IoFatReportColor Border = Color("D9E4F0"); + private static readonly IoFatReportColor Muted = Color("64748B"); + private static readonly IoFatReportColor Ink = Color("1F2937"); + private static readonly IoFatReportColor White = Color("FFFFFF"); + private static readonly IoFatReportColor Pass = Color("15803D"); + private static readonly IoFatReportColor SoftPass = Color("F0FDF4"); + + public static IoFatReportLayoutPlan AppendFileServiceEvidence( + IoTestProject project, + IoFatReportLayoutPlan baseLayout) + { + ArgumentNullException.ThrowIfNull(project); + ArgumentNullException.ThrowIfNull(baseLayout); + + var evidenceIeds = project.Ieds + .Where(ied => ied.HasRemoteComtradeEvidence) + .ToArray(); + if (evidenceIeds.Length == 0) + return baseLayout; + + var supplementalPageCount = (int)Math.Ceiling(evidenceIeds.Length / (double)RowsPerPage); + var totalPages = baseLayout.Pages.Count + supplementalPageCount; + var pages = new List(totalPages); + + for (var index = 0; index < baseLayout.Pages.Count; index++) + { + var page = baseLayout.Pages[index]; + var corrected = page.Commands + .Select(command => CorrectPageTotal(command, index + 1, totalPages)) + .ToArray(); + pages.Add(new IoFatReportPagePlan(index + 1, page.Width, page.Height, corrected)); + } + + for (var pageIndex = 0; pageIndex < supplementalPageCount; pageIndex++) + { + var rows = evidenceIeds + .Skip(pageIndex * RowsPerPage) + .Take(RowsPerPage) + .ToArray(); + var pageNumber = baseLayout.Pages.Count + pageIndex + 1; + pages.Add(BuildEvidencePage(project, rows, pageNumber, totalPages, baseLayout.CreatedAt, baseLayout.Draft)); + } + + return new IoFatReportLayoutPlan( + baseLayout.ProjectId, + baseLayout.CreatedAt, + baseLayout.Draft, + pages); + } + + private static IoFatReportPagePlan BuildEvidencePage( + IoTestProject project, + IReadOnlyList rows, + int pageNumber, + int totalPages, + DateTimeOffset createdAt, + bool draft) + { + var commands = new List(); + var projectName = FirstNonEmpty(project.DocumentControl.ClientProject, project.ProjectName, project.ProjectId); + var documentNumber = FirstNonEmpty( + project.DocumentControl.PurchaserDocumentNumber, + project.DocumentControl.CompanyProjectDocumentNumber, + project.ProjectId); + var revision = FirstNonEmpty(project.DocumentControl.Revision, "-"); + + Line(commands, Margin, 496d, PageWidth - Margin, 496d, Border, 0.8d); + Text(commands, Margin, 566d, 480d, projectName, IoFatReportFontKind.Bold, 7.2d, Muted); + Text(commands, Margin, 544d, 520d, "IEC 61850 File Service / COMTRADE Evidence", IoFatReportFontKind.Bold, 16.8d, Navy); + Text(commands, Margin, 524d, 520d, + "Remote fault-record discovery evidence captured directly from each IED.", + IoFatReportFontKind.Regular, 8.0d, Muted); + + Rect(commands, 590d, 568d, 222d, 64d, 4d, SoftBlue, Border, 0.7d); + Text(commands, 601d, 554d, 200d, "DOCUMENT CONTROL", IoFatReportFontKind.Bold, 5.9d, Muted); + Text(commands, 601d, 538d, 200d, documentNumber, IoFatReportFontKind.Bold, 8.2d, Navy); + Text(commands, 601d, 523d, 200d, $"REV {revision} | {(draft ? "PREVIEW" : "AS TESTED")}", IoFatReportFontKind.Bold, 6.7d, Navy); + Text(commands, 601d, 511d, 200d, draft ? "NOT FOR ISSUE" : "CUSTOMER FAT RECORD", IoFatReportFontKind.Regular, 5.8d, Muted); + + Rect(commands, Margin, 480d, ContentWidth, 42d, 5d, SoftPass, Border, 0.7d); + Text(commands, Margin + 12d, 466d, 170d, "FILE SERVICE ACCEPTANCE BASIS", IoFatReportFontKind.Bold, 5.9d, Pass); + Text(commands, Margin + 12d, 450d, ContentWidth - 24d, + "PASS = the IED returned a supported COMTRADE/fault-record entry through IEC 61850 FileDirectory. FileOpen/FileRead download is optional additional verification.", + IoFatReportFontKind.Regular, 6.6d, Ink); + + var widths = new[] { 105d, 58d, 284d, 108d, 127d, 100d }; + var headers = new[] { "IED", "Result", "Latest remote COMTRADE file(s)", "Relay modified", "Evidence source", "Download" }; + var y = 426d; + var x = Margin; + for (var i = 0; i < headers.Length; i++) + { + Rect(commands, x, y, widths[i], 23d, 0d, SoftBlue, Border, 0.45d); + Text(commands, x + 5d, y - 15d, widths[i] - 10d, headers[i], IoFatReportFontKind.Bold, 5.8d, Blue); + x += widths[i]; + } + y -= 23d; + + foreach (var ied in rows) + { + const double rowHeight = 55d; + x = Margin; + for (var i = 0; i < widths.Length; i++) + { + Rect(commands, x, y, widths[i], rowHeight, 0d, White, Border, 0.35d); + x += widths[i]; + } + + Text(commands, Margin + 5d, y - 17d, widths[0] - 10d, Fit(ied.IedName, 25), IoFatReportFontKind.Bold, 6.5d, Ink); + Text(commands, Margin + 5d, y - 33d, widths[0] - 10d, Fit(ied.IpAddress, 25), IoFatReportFontKind.Mono, 5.7d, Muted); + + var resultX = Margin + widths[0]; + Text(commands, resultX + 5d, y - 24d, widths[1] - 10d, "PASS", IoFatReportFontKind.Bold, 7.2d, Pass); + + var fileX = resultX + widths[1]; + var fileLines = Wrap(ied.LatestComtradeFiles, 52, 3); + var fileY = y - 14d; + foreach (var line in fileLines) + { + Text(commands, fileX + 5d, fileY, widths[2] - 10d, line, IoFatReportFontKind.Mono, 5.9d, Ink); + fileY -= 10.5d; + } + if (!string.IsNullOrWhiteSpace(ied.LatestComtradeCompleteness)) + Text(commands, fileX + 5d, y - 48d, widths[2] - 10d, Fit(ied.LatestComtradeCompleteness, 68), IoFatReportFontKind.Regular, 5.4d, Muted); + + var modifiedX = fileX + widths[2]; + Text(commands, modifiedX + 5d, y - 21d, widths[3] - 10d, + ied.LatestComtradeModifiedAtUtc?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "not supplied", + IoFatReportFontKind.Mono, 5.7d, Ink); + Text(commands, modifiedX + 5d, y - 34d, widths[3] - 10d, + ied.LatestComtradeModifiedAtUtc?.ToString("HH:mm:ss zzz", CultureInfo.InvariantCulture) ?? string.Empty, + IoFatReportFontKind.Mono, 5.7d, Muted); + + var sourceX = modifiedX + widths[3]; + Text(commands, sourceX + 5d, y - 20d, widths[4] - 10d, "IEC 61850", IoFatReportFontKind.Bold, 5.9d, Ink); + Text(commands, sourceX + 5d, y - 34d, widths[4] - 10d, "FileDirectory", IoFatReportFontKind.Mono, 5.7d, Muted); + + var downloadX = sourceX + widths[4]; + Text(commands, downloadX + 5d, y - 20d, widths[5] - 10d, "OPTIONAL", IoFatReportFontKind.Bold, 5.9d, Blue); + Text(commands, downloadX + 5d, y - 34d, widths[5] - 10d, "not a FAT gate", IoFatReportFontKind.Regular, 5.4d, Muted); + + y -= rowHeight; + } + + Line(commands, Margin, 42d, PageWidth - Margin, 42d, Border, 0.6d); + Text(commands, Margin, 24d, 540d, + $"Generated {createdAt:yyyy-MM-dd HH:mm:ss zzz} | Remote listing evidence is IED-scoped and persisted with the FAT project.", + IoFatReportFontKind.Regular, 6.1d, Muted); + Text(commands, PageWidth - Margin - 118d, 24d, 118d, $"Page {pageNumber} / {totalPages}", IoFatReportFontKind.Regular, 6.1d, Muted); + + return new IoFatReportPagePlan(pageNumber, PageWidth, PageHeight, commands); + } + + private static IoFatReportCommand CorrectPageTotal(IoFatReportCommand command, int pageNumber, int totalPages) + { + if (command is IoFatReportTextCommand text && text.Text.StartsWith("Page ", StringComparison.Ordinal)) + return text with { Text = $"Page {pageNumber} / {totalPages}" }; + return command; + } + + private static IReadOnlyList Wrap(string? value, int maxChars, int maxLines) + { + var text = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + var lines = new List(); + while (text.Length > maxChars && lines.Count < maxLines - 1) + { + var split = text.LastIndexOf(' ', maxChars); + if (split < maxChars / 2) + split = maxChars; + lines.Add(text[..split].Trim()); + text = text[split..].Trim(); + } + if (lines.Count < maxLines) + lines.Add(Fit(text, maxChars)); + return lines; + } + + private static string Fit(string? value, int maxChars) + { + var text = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + return text.Length <= maxChars ? text : text[..Math.Max(1, maxChars - 1)] + "…"; + } + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "-"; + + private static IoFatReportColor Color(string hex) => IoFatReportColor.FromHex(hex); + + private static void Rect( + ICollection commands, + double x, + double top, + double width, + double height, + double radius, + IoFatReportColor fill, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportRectCommand(x, top, width, height, radius, fill, stroke, strokeThickness)); + + private static void Line( + ICollection commands, + double x1, + double y1, + double x2, + double y2, + IoFatReportColor stroke, + double strokeThickness) + => commands.Add(new IoFatReportLineCommand(x1, y1, x2, y2, stroke, strokeThickness)); + + private static void Text( + ICollection commands, + double x, + double baselineY, + double width, + string text, + IoFatReportFontKind font, + double fontSize, + IoFatReportColor color) + => commands.Add(new IoFatReportTextCommand(x, baselineY, width, text, font, fontSize, color)); +} \ No newline at end of file diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 6f67c4429..965686195 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -89,7 +89,8 @@ public IoTestLiveBindingSummary Bind( private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevice device) { - if (!point.ImportReady || ImportedReferences(point).Count == 0) + var importedReferences = ImportedReferences(point); + if (!point.ImportReady || importedReferences.Count == 0) { return new PointBinding( IoTestLiveBindingState.SignalNotFound, @@ -98,7 +99,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi null); } - var expectedReferences = ImportedReferences(point) + var expectedReferences = importedReferences .Select(NormalizeReference) .Where(value => value.Length > 0) .ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -116,7 +117,7 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi } var exactSignals = device.Signals - .Where(item => !item.IsControlSignal && + .Where(item => IsSignalEligible(item, point) && expectedReferences.Contains(NormalizeReference(item.ObjectReference))) .ToList(); if (exactSignals.Count == 1) @@ -128,45 +129,84 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi null); } - var expectedTelegrams = ImportedReferences(point) - .SelectMany(reference => NormalizeImportedTelegramForms(reference, point.IedName, point.LogicalNode)) - .Where(value => value.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var livePointCandidates = device.Points - .Where(item => MatchesAnyTelegram(item.IecReference, device, expectedTelegrams)) - .ToList(); + var livePointCandidates = BestCandidates( + device.Points, + item => item.IecReference, + importedReferences, + point, + device); if (livePointCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Live point matched uniquely after normalizing the IED/Application and verified functional-group/LN boundary.", + "Live point matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", livePointCandidates[0].IecReference, livePointCandidates[0]); } - var signalCandidates = device.Signals - .Where(item => !item.IsControlSignal && - MatchesAnyTelegram(item.ObjectReference, device, expectedTelegrams)) - .ToList(); + var signalCandidates = BestCandidates( + device.Signals.Where(item => IsSignalEligible(item, point)).ToList(), + item => item.ObjectReference, + importedReferences, + point, + device); if (signalCandidates.Count == 1) { return new PointBinding( IoTestLiveBindingState.BoundNormalized, - "Discovered signal matched uniquely after normalizing the IED/Application and verified functional-group/LN boundary.", + "Discovered signal matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", signalCandidates[0].ObjectReference, null); } var reason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || signalCandidates.Count > 1 || livePointCandidates.Count > 1 - ? "More than one live candidate matched the imported telegram; automatic binding was withheld." + ? "More than one equally strong IEC 61850 candidate matched the imported telegram; automatic binding was withheld." : device.Signals.Count == 0 ? "The IED is loaded but its signal model has not been discovered yet." - : "None of the imported IEC 61850/event-log references was found in the loaded IED model."; + : "None of the imported IEC 61850/event-log references was found in the loaded IED model after conservative canonical matching."; return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null); } + private static bool IsSignalEligible(SignalDefinition signal, IoTestPointPlan point) + { + if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) + return false; + + return string.IsNullOrWhiteSpace(point.FunctionalConstraint) || + string.IsNullOrWhiteSpace(signal.FunctionalConstraint) || + signal.FunctionalConstraint.Equals(point.FunctionalConstraint, StringComparison.OrdinalIgnoreCase); + } + + private static List BestCandidates( + IReadOnlyCollection candidates, + Func referenceSelector, + IReadOnlyCollection importedReferences, + IoTestPointPlan point, + Iec61850MonitorDevice device) + { + var scored = candidates + .Select(candidate => new + { + Candidate = candidate, + Score = importedReferences.Max(reference => IoTestReferenceMatcher.Score( + reference, + referenceSelector(candidate), + point.IedName, + device.Name, + device.SclIedName, + point.LogicalNode)) + }) + .Where(item => item.Score > 0) + .ToList(); + + if (scored.Count == 0) + return new List(); + + var best = scored.Max(item => item.Score); + return scored.Where(item => item.Score == best).Select(item => item.Candidate).ToList(); + } + internal static IReadOnlyList ImportedReferences(IoTestPointPlan point) { ArgumentNullException.ThrowIfNull(point); @@ -220,21 +260,6 @@ private static string RemoveFunctionalConstraintSuffix(string? reference) return value; } - private static bool MatchesAnyTelegram( - string? observedReference, - Iec61850MonitorDevice device, - IReadOnlySet expectedTelegrams) - { - if (expectedTelegrams.Count == 0) - return false; - - if (expectedTelegrams.Contains(NormalizeTelegram(observedReference, device.Name))) - return true; - - return !string.IsNullOrWhiteSpace(device.SclIedName) && - expectedTelegrams.Contains(NormalizeTelegram(observedReference, device.SclIedName)); - } - private static Iec61850MonitorDevice? FindDevice( IoTestIedPlan plan, IReadOnlyCollection devices) @@ -259,81 +284,16 @@ private static bool DeviceNameMatches(Iec61850MonitorDevice device, string iedNa device.SclIedName.Equals(iedName, StringComparison.OrdinalIgnoreCase); internal static string NormalizeReference(string? reference) - => (reference ?? string.Empty) - .Trim() - .Replace('$', '.') - .Replace("..", ".") - .TrimEnd('.') - .ToLowerInvariant(); + => IoTestReferenceMatcher.NormalizeRaw(reference); internal static string NormalizeTelegram(string? reference, string? iedName) - { - var normalized = NormalizeReference(RemoveFunctionalConstraintSuffix(reference)); - var slash = normalized.IndexOf('/'); - var name = (iedName ?? string.Empty).Trim().ToLowerInvariant(); - if (slash <= 0 || string.IsNullOrWhiteSpace(name)) - return normalized; - - var domain = normalized[..slash]; - if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase)) - return normalized; - - var domainSuffix = domain[name.Length..]; - var path = normalized[(slash + 1)..].TrimStart('/'); - - // FAT source/report traceability can use IEDNameApplication/FunctionGroup/LN.DO.DA, - // while the live MMS model exposes the same function group as an LN prefix. - // "Application" is a display wrapper, not part of the live telegram identity. - if (domainSuffix.Equals("application", StringComparison.OrdinalIgnoreCase)) - return path; - - return domainSuffix.Length == 0 ? path : domainSuffix + "/" + path; - } + => IoTestReferenceMatcher.NormalizeTelegram(reference, iedName); internal static IReadOnlySet NormalizeImportedTelegramForms( string? reference, string? iedName, string? logicalNode) - { - var forms = new HashSet(StringComparer.OrdinalIgnoreCase); - var normalized = NormalizeTelegram(reference, iedName); - if (string.IsNullOrWhiteSpace(normalized)) - return forms; - - forms.Add(normalized); - var collapsed = CollapseVerifiedDisplayHierarchy(normalized, logicalNode); - if (!string.IsNullOrWhiteSpace(collapsed)) - forms.Add(collapsed); - return forms; - } - - private static string CollapseVerifiedDisplayHierarchy(string normalizedTelegram, string? logicalNode) - { - var value = (normalizedTelegram ?? string.Empty).Trim(); - var verifiedLn = NormalizeReference(logicalNode).Trim('/'); - if (string.IsNullOrWhiteSpace(verifiedLn)) - return value; - - var firstDot = value.IndexOf('.'); - if (firstDot <= 0) - return value; - - var logicalNodePath = value[..firstDot]; - var lastSlash = logicalNodePath.LastIndexOf('/'); - if (lastSlash <= 0 || lastSlash >= logicalNodePath.Length - 1) - return value; - - var terminalLn = logicalNodePath[(lastSlash + 1)..]; - if (!terminalLn.Equals(verifiedLn, StringComparison.OrdinalIgnoreCase)) - return value; - - // Siemens/DIGSI source exports can render a verified LN prefix as folders, - // e.g. ADD/GGIO1. Only collapse the imported pre-DO hierarchy when the final - // segment exactly equals the workbook LN metadata. Observed/live references - // are never collapsed, preventing AB/GGIO1 from matching A/BGGIO1 by accident. - var collapsedLogicalNode = logicalNodePath.Replace("/", string.Empty, StringComparison.Ordinal); - return collapsedLogicalNode + value[firstDot..]; - } + => IoTestReferenceMatcher.ImportedForms(reference, iedName, logicalNode); private sealed record PointBinding( IoTestLiveBindingState State, diff --git a/Services/IoTesting/IoTestProjectPersistenceService.cs b/Services/IoTesting/IoTestProjectPersistenceService.cs index 7daae63fd..92cffa195 100644 --- a/Services/IoTesting/IoTestProjectPersistenceService.cs +++ b/Services/IoTesting/IoTestProjectPersistenceService.cs @@ -407,7 +407,17 @@ private void SaveFromTimer() point.Runtime.Attempt, point.Runtime.CurrentValue, point.Runtime.CurrentQuality, - point.Runtime.CurrentSource))).ToList())).ToList())); + point.Runtime.CurrentSource))).ToList()) + { + LatestComtradeFiles = ied.LatestComtradeFiles, + LatestComtradeRemotePath = ied.LatestComtradeRemotePath, + LatestComtradeCompleteness = ied.LatestComtradeCompleteness, + LatestComtradeAcquisitionSource = ied.LatestComtradeAcquisitionSource, + LatestComtradeModifiedAtUtc = ied.LatestComtradeModifiedAtUtc, + LatestComtradeCapturedAtUtc = ied.LatestComtradeCapturedAtUtc, + LatestComtradeFileCount = ied.LatestComtradeFileCount, + LatestComtradeKnownSizeBytes = ied.LatestComtradeKnownSizeBytes + }).ToList())); private static async Task LoadSnapshotAsync(string path, CancellationToken cancellationToken) { @@ -438,6 +448,14 @@ private static IoTestProject RestoreProject(IoTestProjectSnapshot snapshot) Location = ied.Location, VoltageLevel = ied.VoltageLevel, Switchgear = ied.Switchgear, + LatestComtradeFiles = ied.LatestComtradeFiles ?? string.Empty, + LatestComtradeRemotePath = ied.LatestComtradeRemotePath ?? string.Empty, + LatestComtradeCompleteness = ied.LatestComtradeCompleteness ?? string.Empty, + LatestComtradeAcquisitionSource = ied.LatestComtradeAcquisitionSource ?? string.Empty, + LatestComtradeModifiedAtUtc = ied.LatestComtradeModifiedAtUtc, + LatestComtradeCapturedAtUtc = ied.LatestComtradeCapturedAtUtc, + LatestComtradeFileCount = ied.LatestComtradeFileCount, + LatestComtradeKnownSizeBytes = ied.LatestComtradeKnownSizeBytes, TestPoints = ied.TestPoints.Select(RestorePoint).ToList() }).ToList() }; @@ -510,7 +528,7 @@ private static string BuildPrintableReport(IoTestProject project) var builder = new StringBuilder(); builder.Append("") .Append(Html(project.ProjectName)).Append(" - IO FAT Report"); + .Append("body{font-family:Segoe UI,Arial,sans-serif;color:#172033;margin:28px}h1{margin:0;color:#2458b8}h2{margin-top:30px;border-bottom:2px solid #dbe6f6;padding-bottom:6px}.meta{background:#f5f8fd;border:1px solid #dbe6f6;border-radius:10px;padding:14px;margin:16px 0}.summary{display:flex;gap:12px;flex-wrap:wrap}.pill{border:1px solid #dbe6f6;border-radius:16px;padding:7px 12px;background:#fff}.file-evidence{background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:10px 12px;margin:8px 0 14px}table{width:100%;border-collapse:collapse;font-size:11px}th,td{border:1px solid #cdd8e8;padding:6px;vertical-align:top}th{background:#eaf1fb;text-align:left}.pass{color:#08783f;font-weight:700}.review,.failed{color:#a75800;font-weight:700}.pending{color:#667085}@media print{body{margin:8mm}.ied{page-break-before:always}.ied:first-of-type{page-break-before:auto}.no-print{display:none}}@page{size:A4 landscape;margin:8mm}"); builder.Append("

ARSAS IO List FAT Evidence Report

Project: ").Append(Html(project.ProjectName)) .Append("
Project ID: ").Append(Html(project.ProjectId)) .Append("
Source workbook: ").Append(Html(project.SourceWorkbookName)) @@ -524,8 +542,20 @@ private static string BuildPrintableReport(IoTestProject project) foreach (var ied in project.Ieds) { builder.Append("

").Append(Html(ied.IedName)).Append(" · ").Append(Html(ied.IpAddress)).Append("

") - .Append("

").Append(Html(ied.IedRole)).Append(" · ").Append(Html(ied.Location)).Append(" · ").Append(Html(ied.VoltageLevel)).Append("

") - .Append(""); + .Append("

").Append(Html(ied.IedRole)).Append(" · ").Append(Html(ied.Location)).Append(" · ").Append(Html(ied.VoltageLevel)).Append("

"); + + if (ied.HasRemoteComtradeEvidence) + { + builder.Append("
IEC 61850 File Service: PASS") + .Append("
Latest remote COMTRADE: ").Append(Html(ied.LatestComtradeFiles)) + .Append("
Remote path: ").Append(Html(ied.LatestComtradeRemotePath)) + .Append("
Relay modified: ").Append(Html(ied.LatestComtradeModifiedAtUtc?.ToString("yyyy-MM-dd HH:mm:ss zzz") ?? "not supplied")) + .Append("
Evidence source: IEC 61850 FileDirectory") + .Append("
Download: Optional additional verification; not a FAT gate.") + .Append("
"); + } + + builder.Append("
#SignalIEC 61850 referenceExpected ON/OFFON evidenceOFF evidenceResultReason
"); var index = 0; foreach (var point in ied.TestPoints) { @@ -702,7 +732,17 @@ private sealed record IoTestIedData( string Location, string VoltageLevel, string Switchgear, - List TestPoints); + List TestPoints) + { + public string LatestComtradeFiles { get; init; } = string.Empty; + public string LatestComtradeRemotePath { get; init; } = string.Empty; + public string LatestComtradeCompleteness { get; init; } = string.Empty; + public string LatestComtradeAcquisitionSource { get; init; } = string.Empty; + public DateTimeOffset? LatestComtradeModifiedAtUtc { get; init; } + public DateTimeOffset? LatestComtradeCapturedAtUtc { get; init; } + public int LatestComtradeFileCount { get; init; } + public long LatestComtradeKnownSizeBytes { get; init; } + } private sealed record IoTestPointData( string TestPointId, @@ -761,4 +801,4 @@ private sealed record IoTestPackageEvidence( string Sha256, long RecordCount, string LastHash); -} +} \ No newline at end of file diff --git a/Services/IoTesting/IoTestReferenceMatcher.cs b/Services/IoTesting/IoTestReferenceMatcher.cs new file mode 100644 index 000000000..bee398fea --- /dev/null +++ b/Services/IoTesting/IoTestReferenceMatcher.cs @@ -0,0 +1,211 @@ +using System.Text.RegularExpressions; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Conservative IEC 61850 reference matcher used by FAT binding. It understands +/// equivalent MMS/SCL spellings (IED-prefixed domains, the DIGSI Application display +/// wrapper, functional-constraint tokens inside MMS references, and verified Siemens +/// functional-group/LN display folders) but never uses fuzzy text similarity. +/// +internal static class IoTestReferenceMatcher +{ + private static readonly HashSet FunctionalConstraints = new(StringComparer.OrdinalIgnoreCase) + { + "ST", "MX", "SP", "SV", "CF", "DC", "SG", "SE", "SR", "OR", "BL", "EX", + "CO", "RP", "BR", "LG", "GO", "GS", "MS", "US" + }; + + private static readonly HashSet SafeImplicitValueLeaves = new(StringComparer.OrdinalIgnoreCase) + { + "stval", "general", "f", "i", "mag.f", "cval.mag.f", "instcval.mag.f", "valwtr.posval" + }; + + internal const int ExactScore = 100; + internal const int CanonicalScore = 90; + internal const int ContainerScore = 70; + + internal static int Score( + string? importedReference, + string? observedReference, + string? importedIedName, + string? observedDeviceName, + string? observedSclIedName, + string? logicalNode) + { + var importedRaw = NormalizeRaw(importedReference); + var observedRaw = NormalizeRaw(observedReference); + if (importedRaw.Length == 0 || observedRaw.Length == 0) + return 0; + + if (importedRaw.Equals(observedRaw, StringComparison.OrdinalIgnoreCase)) + return ExactScore; + + var importedForms = ImportedForms(importedReference, importedIedName, logicalNode); + var observedForms = ObservedForms(observedReference, observedDeviceName, observedSclIedName); + if (importedForms.Overlaps(observedForms)) + return CanonicalScore; + + foreach (var expected in importedForms) + { + foreach (var observed in observedForms) + { + if (IsSafeImplicitLeafMatch(expected, observed)) + return ContainerScore; + } + } + + return 0; + } + + internal static HashSet ImportedForms( + string? reference, + string? iedName, + string? logicalNode) + { + var forms = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCanonicalForms(forms, reference, new[] { iedName }); + + foreach (var form in forms.ToArray()) + { + var collapsed = CollapseVerifiedDisplayHierarchy(form, logicalNode); + if (!string.IsNullOrWhiteSpace(collapsed)) + forms.Add(collapsed); + } + + return forms; + } + + internal static HashSet ObservedForms( + string? reference, + string? deviceName, + string? sclIedName) + { + var forms = new HashSet(StringComparer.OrdinalIgnoreCase); + AddCanonicalForms(forms, reference, new[] { deviceName, sclIedName }); + return forms; + } + + internal static string NormalizeRaw(string? reference) + { + var value = RemoveFunctionalConstraintSuffix(reference); + if (value.Length == 0) + return string.Empty; + + value = NormalizeMmsFunctionalConstraint(value); + value = value.Replace('\\', '/'); + value = Regex.Replace(value, @"/{2,}", "/"); + value = Regex.Replace(value, @"\.{2,}", "."); + return value.Trim().TrimEnd('.').ToLowerInvariant(); + } + + internal static string NormalizeTelegram(string? reference, string? iedName) + { + var normalized = NormalizeRaw(reference); + if (normalized.Length == 0) + return string.Empty; + + var slash = normalized.IndexOf('/'); + var name = (iedName ?? string.Empty).Trim().ToLowerInvariant(); + if (slash <= 0 || name.Length == 0) + return normalized; + + var domain = normalized[..slash]; + if (!domain.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + return normalized; + + var suffix = domain[name.Length..]; + var path = normalized[(slash + 1)..].TrimStart('/'); + if (suffix.Equals("application", StringComparison.OrdinalIgnoreCase)) + return path; + + return suffix.Length == 0 ? path : suffix + "/" + path; + } + + private static void AddCanonicalForms( + ISet forms, + string? reference, + IEnumerable iedNames) + { + var raw = NormalizeRaw(reference); + if (raw.Length == 0) + return; + + forms.Add(raw); + foreach (var name in iedNames.Where(value => !string.IsNullOrWhiteSpace(value))) + { + var telegram = NormalizeTelegram(raw, name); + if (telegram.Length > 0) + forms.Add(telegram); + } + } + + private static string NormalizeMmsFunctionalConstraint(string value) + { + if (!value.Contains('$')) + return value; + + var slash = value.IndexOf('/'); + if (slash < 0 || slash >= value.Length - 1) + return value.Replace('$', '.'); + + var domain = value[..(slash + 1)]; + var path = value[(slash + 1)..]; + var tokens = path.Split('$', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + if (tokens.Count >= 3 && FunctionalConstraints.Contains(tokens[1])) + tokens.RemoveAt(1); + + return domain + string.Join('.', tokens); + } + + private static string RemoveFunctionalConstraintSuffix(string? reference) + { + var value = (reference ?? string.Empty).Trim(); + var marker = value.LastIndexOf(" [", StringComparison.Ordinal); + if (marker > 0 && value.EndsWith(']')) + { + var suffix = value[(marker + 2)..^1].Trim(); + if (FunctionalConstraints.Contains(suffix)) + value = value[..marker].TrimEnd(); + } + return value; + } + + private static string CollapseVerifiedDisplayHierarchy(string normalizedTelegram, string? logicalNode) + { + var value = (normalizedTelegram ?? string.Empty).Trim(); + var verifiedLn = NormalizeRaw(logicalNode).Trim('/'); + if (value.Length == 0 || verifiedLn.Length == 0) + return value; + + var firstDot = value.IndexOf('.'); + if (firstDot <= 0) + return value; + + var logicalNodePath = value[..firstDot]; + var lastSlash = logicalNodePath.LastIndexOf('/'); + if (lastSlash <= 0 || lastSlash >= logicalNodePath.Length - 1) + return value; + + var terminalLn = logicalNodePath[(lastSlash + 1)..]; + if (!terminalLn.Equals(verifiedLn, StringComparison.OrdinalIgnoreCase)) + return value; + + // Collapse only an imported hierarchy whose terminal segment is the verified LN. + // The observed/live side is never collapsed; this intentionally prevents a path + // such as AB/GGIO1 from matching a different A/BGGIO1 boundary by accident. + return logicalNodePath.Replace("/", string.Empty, StringComparison.Ordinal) + value[firstDot..]; + } + + private static bool IsSafeImplicitLeafMatch(string expected, string observed) + { + if (expected.Length == 0 || observed.Length <= expected.Length || + !observed.StartsWith(expected + ".", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var suffix = observed[(expected.Length + 1)..]; + return SafeImplicitValueLeaves.Contains(suffix); + } +} diff --git a/Services/IoTesting/IoTestSignalSelectionService.cs b/Services/IoTesting/IoTestSignalSelectionService.cs index e554ce631..08747a50f 100644 --- a/Services/IoTesting/IoTestSignalSelectionService.cs +++ b/Services/IoTesting/IoTestSignalSelectionService.cs @@ -20,9 +20,8 @@ public sealed record IoTestSignalSelectionResult( /// /// Resolves the enabled IO-list scope against one discovered IED model without -/// guessing. Exact source/event-log references are preferred. Normalized IED, -/// Application and verified vendor functional-group/LN-prefix forms are accepted -/// only when they produce one unique non-control signal. +/// guessing. Exact references remain highest priority. Canonical IEC 61850 forms +/// accept vendor-safe spelling differences only when the best candidate is unique. /// public sealed class IoTestSignalSelectionService { @@ -43,40 +42,45 @@ public IoTestSignalSelectionResult Resolve( foreach (var point in requested) { - var exactReferences = IoTestLiveBindingService.ImportedReferences(point) - .Select(IoTestLiveBindingService.NormalizeReference) - .Where(value => value.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var candidates = device.Signals + var importedReferences = IoTestLiveBindingService.ImportedReferences(point); + var scored = device.Signals .Where(signal => IsEligible(signal, point)) - .Where(signal => exactReferences.Contains( - IoTestLiveBindingService.NormalizeReference(signal.ObjectReference))) + .Select(signal => new ScoredSignal( + signal, + importedReferences.Count == 0 + ? 0 + : importedReferences.Max(reference => IoTestReferenceMatcher.Score( + reference, + signal.ObjectReference, + ied.IedName, + device.Name, + device.SclIedName, + point.LogicalNode)))) + .Where(item => item.Score > 0) .ToList(); - var usedNormalizedPrefix = false; - if (candidates.Count == 0) - { - candidates = device.Signals - .Where(signal => IsEligible(signal, point)) - .Where(signal => NormalizedTelegramMatches(signal, point, ied, device)) - .ToList(); - usedNormalizedPrefix = true; - } - - if (candidates.Count == 0) + if (scored.Count == 0) { missing.Add(point); continue; } + var bestScore = scored.Max(item => item.Score); + var candidates = scored + .Where(item => item.Score == bestScore) + .Select(item => item.Signal) + .ToList(); + if (candidates.Count != 1 || !usedSignals.Add(candidates[0])) { ambiguous.Add(point); continue; } - matches.Add(new IoTestSignalMatch(point, candidates[0], usedNormalizedPrefix)); + matches.Add(new IoTestSignalMatch( + point, + candidates[0], + bestScore < IoTestReferenceMatcher.ExactScore)); } if (missing.Count > 0 || ambiguous.Count > 0) @@ -93,11 +97,15 @@ public IoTestSignalSelectionResult Resolve( string.Join(" ", details)); } + var smartCount = matches.Count(match => match.UsedNormalizedIedPrefix); + var smartText = smartCount == 0 + ? string.Empty + : $" {smartCount} used unique canonical IEC 61850 matching."; return new IoTestSignalSelectionResult( matches, missing, ambiguous, - $"Resolved {matches.Count} enabled IO-list signal(s) to unique discovered model points."); + $"Resolved {matches.Count} enabled IO-list signal(s) to unique discovered model points.{smartText}"); } private static bool IsEligible(SignalDefinition signal, IoTestPointPlan point) @@ -110,33 +118,6 @@ private static bool IsEligible(SignalDefinition signal, IoTestPointPlan point) signal.FunctionalConstraint.Equals(point.FunctionalConstraint, StringComparison.OrdinalIgnoreCase); } - private static bool NormalizedTelegramMatches( - SignalDefinition signal, - IoTestPointPlan point, - IoTestIedPlan ied, - Iec61850MonitorDevice device) - { - var expected = IoTestLiveBindingService.ImportedReferences(point) - .SelectMany(reference => IoTestLiveBindingService.NormalizeImportedTelegramForms( - reference, - ied.IedName, - point.LogicalNode)) - .Where(value => value.Length > 0) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - if (expected.Count == 0) - return false; - - var observed = IoTestLiveBindingService.NormalizeTelegram(signal.ObjectReference, device.Name); - if (expected.Contains(observed)) - return true; - - if (string.IsNullOrWhiteSpace(device.SclIedName)) - return false; - - observed = IoTestLiveBindingService.NormalizeTelegram(signal.ObjectReference, device.SclIedName); - return expected.Contains(observed); - } - private static string Describe(IReadOnlyCollection points) { var values = points @@ -147,4 +128,6 @@ private static string Describe(IReadOnlyCollection points) values.Add($"…and {points.Count - values.Count} more"); return string.Join(", ", values); } + + private sealed record ScoredSignal(SignalDefinition Signal, int Score); } diff --git a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs index 0587cb099..6a72a4ac4 100644 --- a/Services/IoTesting/IoTestWorkspaceBootstrapService.cs +++ b/Services/IoTesting/IoTestWorkspaceBootstrapService.cs @@ -130,8 +130,10 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot throw new InvalidDataException("The local snapshot belongs to a different workbook or schema."); } - var savedPoints = RequiredArray(savedProject, "ieds") - .EnumerateArray() + var savedIeds = RequiredArray(savedProject, "ieds").EnumerateArray().ToArray(); + RestoreIedLevelEvidence(project, savedIeds); + + var savedPoints = savedIeds .SelectMany(ied => RequiredArray(ied, "testPoints").EnumerateArray()) .ToDictionary(point => RequiredString(point, "testPointId"), StringComparer.OrdinalIgnoreCase); @@ -177,6 +179,29 @@ private static void ApplySnapshotProgress(IoTestProject project, string snapshot project.InitializeRuntimeNotifications(); } + private static void RestoreIedLevelEvidence(IoTestProject project, IReadOnlyList savedIeds) + { + foreach (var ied in project.Ieds) + { + var saved = savedIeds.FirstOrDefault(candidate => + OptionalString(candidate, "iedName", string.Empty).Equals(ied.IedName, StringComparison.OrdinalIgnoreCase) && + OptionalString(candidate, "ipAddress", string.Empty).Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)); + if (saved.ValueKind != JsonValueKind.Object) + continue; + + // These fields are optional so snapshots written before COMTRADE FAT evidence + // was introduced remain fully readable. + ied.LatestComtradeFiles = OptionalString(saved, "latestComtradeFiles", string.Empty); + ied.LatestComtradeRemotePath = OptionalString(saved, "latestComtradeRemotePath", string.Empty); + ied.LatestComtradeCompleteness = OptionalString(saved, "latestComtradeCompleteness", string.Empty); + ied.LatestComtradeAcquisitionSource = OptionalString(saved, "latestComtradeAcquisitionSource", string.Empty); + ied.LatestComtradeModifiedAtUtc = OptionalDateTimeOffset(saved, "latestComtradeModifiedAtUtc"); + ied.LatestComtradeCapturedAtUtc = OptionalDateTimeOffset(saved, "latestComtradeCapturedAtUtc"); + ied.LatestComtradeFileCount = OptionalInt(saved, "latestComtradeFileCount", 0); + ied.LatestComtradeKnownSizeBytes = OptionalLong(saved, "latestComtradeKnownSizeBytes", 0L); + } + } + private static void ExcludeCompletedFromNextSession(IoTestProject project) { foreach (var point in project.Ieds.SelectMany(ied => ied.TestPoints)) @@ -244,6 +269,18 @@ private static int OptionalInt(JsonElement parent, string property, int fallback ? number : fallback; + private static long OptionalLong(JsonElement parent, string property, long fallback) + => parent.TryGetProperty(property, out var value) && value.TryGetInt64(out var number) + ? number + : fallback; + + private static DateTimeOffset? OptionalDateTimeOffset(JsonElement parent, string property) + => parent.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String && + value.TryGetDateTimeOffset(out var timestamp) + ? timestamp + : null; + private static string ProjectDirectory(string root, IoTestProject project) { var hash = string.IsNullOrWhiteSpace(project.SourceWorkbookSha256) @@ -258,4 +295,4 @@ private static string Sanitize(string value) var result = new string((value ?? "IO-TEST").Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim(); return result.Length == 0 ? "IO-TEST" : result; } -} +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoFatComtradePersistenceTests.cs b/tests/ARSAS.Tests/IoFatComtradePersistenceTests.cs new file mode 100644 index 000000000..c3fdd18fc --- /dev/null +++ b/tests/ARSAS.Tests/IoFatComtradePersistenceTests.cs @@ -0,0 +1,83 @@ +using System.Security.Cryptography; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatComtradePersistenceTests +{ + [Fact] + public async Task LatestRemoteComtrade_SurvivesWorkspaceSnapshotRestore() + { + var root = Path.Combine(Path.GetTempPath(), "ARSAS.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var workbook = Path.Combine(root, "source.xlsx"); + await File.WriteAllBytesAsync(workbook, new byte[] { 1, 6, 1, 8, 5, 0 }); + var hash = Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(workbook))).ToLowerInvariant(); + var projectsRoot = Path.Combine(root, "projects"); + var evidenceRoot = Path.Combine(root, "evidence"); + + var initial = BuildProject(hash); + var initialSession = Session(initial, evidenceRoot); + var opened = await IoTestWorkspacePersistence.OpenWorkbookAsync( + initial, + initialSession, + workbook, + projectsRoot, + evidenceRoot); + using (initialSession) + using (opened.Workspace) + { + var ied = opened.Project.Ieds[0]; + ied.LatestComtradeFiles = "FRA00028.cfg + FRA00028.dat"; + ied.LatestComtradeRemotePath = "FRA00028.cfg"; + ied.LatestComtradeCompleteness = "CFG + DAT"; + ied.LatestComtradeAcquisitionSource = IoFatRemoteComtradeEvidenceService.AcquisitionSource; + ied.LatestComtradeModifiedAtUtc = new DateTimeOffset(2026, 8, 12, 8, 0, 0, TimeSpan.Zero); + ied.LatestComtradeCapturedAtUtc = new DateTimeOffset(2026, 8, 12, 9, 0, 0, TimeSpan.Zero); + ied.LatestComtradeFileCount = 2; + ied.LatestComtradeKnownSizeBytes = 4096; + opened.Workspace.SaveNow(); + } + + var restored = await IoTestWorkspaceBootstrapService.OpenWorkbookAsync( + BuildProject(hash), + workbook, + projectsRoot, + evidenceRoot, + Session); + using (restored.Session) + using (restored.Workspace) + { + var ied = restored.Project.Ieds[0]; + Assert.True(restored.RestoredProgress); + Assert.True(ied.HasRemoteComtradeEvidence); + Assert.Equal("FRA00028.cfg + FRA00028.dat", ied.LatestComtradeFiles); + Assert.Equal("FRA00028.cfg", ied.LatestComtradeRemotePath); + Assert.Equal("CFG + DAT", ied.LatestComtradeCompleteness); + Assert.Equal(IoFatRemoteComtradeEvidenceService.AcquisitionSource, ied.LatestComtradeAcquisitionSource); + Assert.Equal(2, ied.LatestComtradeFileCount); + Assert.Equal(4096, ied.LatestComtradeKnownSizeBytes); + } + } + + private static IoTestProject BuildProject(string hash) => new() + { + ProjectId = "COMTRADE-PERSISTENCE", + SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, + ProjectName = "COMTRADE Persistence", + SourceWorkbookName = "source.xlsx", + SourceWorkbookSha256 = hash, + Ieds = + [ + new IoTestIedPlan + { + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70" + } + ] + }; + + private static IoTestSessionController Session(IoTestProject project, string evidenceRoot) + => new(project, _ => null, action => action(), evidenceRoot); +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs b/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs new file mode 100644 index 000000000..82d94acd7 --- /dev/null +++ b/tests/ARSAS.Tests/IoFatRemoteComtradeEvidenceTests.cs @@ -0,0 +1,111 @@ +using System.Text; +using AR.Iec61850.FaultRecords; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoFatRemoteComtradeEvidenceTests +{ + [Fact] + public void CaptureLatest_SelectsNewestRelayRecordWithoutRequiringDownload() + { + var project = BuildProject(); + var ied = project.Ieds[0]; + var older = BuildRecord("FRA00027", new DateTimeOffset(2026, 8, 12, 7, 0, 0, TimeSpan.Zero)); + var latest = BuildRecord("FRA00028", new DateTimeOffset(2026, 8, 12, 8, 0, 0, TimeSpan.Zero)); + + var evidence = IoFatRemoteComtradeEvidenceService.CaptureLatest( + storage: null, + project, + ied, + new[] { older, latest }); + + Assert.NotNull(evidence); + Assert.Equal("PASS", evidence!.Verdict); + Assert.Equal(IoFatRemoteComtradeEvidenceService.AcquisitionSource, evidence.AcquisitionSource); + Assert.Equal("FRA00028.cfg + FRA00028.dat", ied.LatestComtradeFiles); + Assert.Equal("FRA00028.cfg", ied.LatestComtradeRemotePath); + Assert.Equal(latest.LastModifiedUtc, ied.LatestComtradeModifiedAtUtc); + Assert.True(ied.HasRemoteComtradeEvidence); + Assert.Contains("download is optional", evidence.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FatPdf_IncludesLatestRemoteComtradeAsFileServiceEvidence() + { + var project = BuildProject(); + var ied = project.Ieds[0]; + IoFatRemoteComtradeEvidenceService.CaptureLatest( + storage: null, + project, + ied, + new[] { BuildRecord("FRA00028", new DateTimeOffset(2026, 8, 12, 8, 0, 0, TimeSpan.Zero)) }); + + var pdf = IoFatPdfReportService.Generate( + project, + new DateTimeOffset(2026, 8, 12, 19, 0, 0, TimeSpan.FromHours(7))); + var text = Encoding.ASCII.GetString(pdf); + + Assert.Contains("File Service / COMTRADE Evidence", text, StringComparison.Ordinal); + Assert.Contains("FRA00028.cfg + FRA00028.dat", text, StringComparison.Ordinal); + Assert.Contains("FileDirectory", text, StringComparison.Ordinal); + Assert.Contains("OPTIONAL", text, StringComparison.Ordinal); + Assert.Contains("not a FAT gate", text, StringComparison.Ordinal); + } + + private static Iec61850FaultRecordSet BuildRecord(string baseName, DateTimeOffset modified) + => new() + { + RecordId = baseName, + BaseName = baseName, + RemoteDirectory = string.Empty, + LastModifiedUtc = modified, + Completeness = "CFG + DAT", + KnownSizeBytes = 4096, + Files = + [ + new Iec61850FaultRecordFile + { + Name = baseName + ".dat", + RemotePath = baseName + ".dat", + BaseName = baseName, + Extension = ".dat", + LastModifiedUtc = modified, + SizeBytes = 3072 + }, + new Iec61850FaultRecordFile + { + Name = baseName + ".cfg", + RemotePath = baseName + ".cfg", + BaseName = baseName, + Extension = ".cfg", + LastModifiedUtc = modified, + SizeBytes = 1024 + } + ] + }; + + private static IoTestProject BuildProject() => new() + { + ProjectId = "FAT-COMTRADE-TEST", + SchemaVersion = IoTestImportValidator.SupportedSchemaVersion, + ProjectName = "FAT COMTRADE Test", + SourceWorkbookName = "fat.xlsx", + SourceWorkbookSha256 = new string('a', 64), + DocumentControl = new IoFatDocumentControl + { + ClientProject = "FAT COMTRADE Test", + PurchaserDocumentNumber = "DOC-001", + Revision = "01" + }, + Ieds = + [ + new IoTestIedPlan + { + IedName = "AA1C1F03R4", + IpAddress = "192.168.81.70" + } + ] + }; +} \ No newline at end of file diff --git a/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs new file mode 100644 index 000000000..20cd702e4 --- /dev/null +++ b/tests/ARSAS.Tests/IoTestSmartReferenceMatcherTests.cs @@ -0,0 +1,119 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Models.IoTesting; +using ArIED61850Tester.Services.IoTesting; + +namespace ARSAS.Tests; + +public sealed class IoTestSmartReferenceMatcherTests +{ + private readonly IoTestSignalSelectionService _service = new(); + + [Fact] + public void MmsFunctionalConstraintTokens_DoNotCauseSignalMissing() + { + var ied = Ied(Point("TP-001", "Protection/DifPDIF1.Op.general", "ST", "DifPDIF1")); + var device = Device(Signal( + "Differential operate", + "AA1C1F06R2Protection/DifPDIF1$ST$Op$general", + "ST")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + var match = Assert.Single(result.Matches); + Assert.Equal("Differential operate", match.Signal.Name); + Assert.True(match.UsedNormalizedIedPrefix); + } + + [Fact] + public void IedPrefixedApplicationAndMmsFcTokens_MatchTogether() + { + var ied = Ied(Point( + "TP-002", + "AA1C1F06R2Application/System/LLN0.SyncSt.stVal", + "ST", + "LLN0")); + var device = Device(Signal( + "Clock sync status", + "AA1C1F06R2Application/SystemLLN0$ST$SyncSt$stVal", + "ST")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Single(result.Matches); + } + + [Fact] + public void MissingDefaultStVal_IsAcceptedOnlyWhenCandidateIsUnique() + { + var ied = Ied(Point("TP-003", "CTRL/SPo32GGIO1.Ind1", "ST", "SPo32GGIO1")); + var device = Device(Signal( + "Binary indication", + "AA1C1F06R2CTRL/SPo32GGIO1$ST$Ind1$stVal", + "ST")); + + var result = _service.Resolve(ied, device); + + Assert.True(result.Succeeded, result.Message); + Assert.Single(result.Matches); + } + + [Fact] + public void EqualSmartCandidates_RemainAmbiguousInsteadOfBeingGuessed() + { + var ied = Ied(Point("TP-004", "CTRL/SPo32GGIO1.Ind1", "ST", "SPo32GGIO1")); + var device = Device( + Signal("A", "AA1C1F06R2CTRL/SPo32GGIO1$ST$Ind1$stVal", "ST"), + Signal("B", "AA1C1F06R2CTRL/SPo32GGIO1.Ind1.stVal", "ST")); + + var result = _service.Resolve(ied, device); + + Assert.False(result.Succeeded); + Assert.Single(result.AmbiguousPoints); + Assert.Empty(result.MissingPoints); + } + + private static IoTestIedPlan Ied(params IoTestPointPlan[] points) => new() + { + IedName = "AA1C1F06R2", + IpAddress = "192.168.81.53", + IedRole = "Protection relay", + TestPoints = points.ToList() + }; + + private static IoTestPointPlan Point(string id, string reference, string fc, string logicalNode) => new() + { + TestPointId = id, + IedName = "AA1C1F06R2", + IpAddress = "192.168.81.53", + SignalName = id, + ObjectReference = reference, + LogicalNode = logicalNode, + FunctionalConstraint = fc, + ExpectedOnText = "Active", + ExpectedOffText = "InActive", + ImportReady = true, + TestEnabled = true + }; + + private static Iec61850MonitorDevice Device(params SignalDefinition[] signals) + { + var device = new Iec61850MonitorDevice + { + Name = "AA1C1F06R2", + SclIedName = "AA1C1F06R2", + IpAddress = "192.168.81.53", + Port = 102 + }; + device.Signals.AddRange(signals); + return device; + } + + private static SignalDefinition Signal(string name, string reference, string fc) => new() + { + Name = name, + ObjectReference = reference, + FunctionalConstraint = fc + }; +}
#SignalIEC 61850 referenceExpected ON/OFFON evidenceOFF evidenceResultReason