-
Notifications
You must be signed in to change notification settings - Fork 4
FAT: smart IEC 61850 binding + COMTRADE/time-sync evidence #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bb1d891
515c7d8
f137f4e
db843c9
aca1833
5f3b7fd
0d61399
9f1ff24
b8165b9
e3b3e21
1098ea3
bd3a453
8c6d5e9
1753de7
1884ea9
fa761a1
7e278ea
eec709c
655ec83
46be2dc
599d640
13883e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; } | ||
|
Comment on lines
+55
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding these serialized properties invalidates every journal created by an earlier release: Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| public sealed record IoTestJournalEnvelope( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On the saved-model fast-reconnect path, cached
SignalDefinitioninstances start withValue = "-", while monitoring publishes live values intodevice.Pointsand does not copy them back into the signal. This loop therefore waits on a value that never changes andCaptureTimeSyncsubsequently reads the same stale signal, recordingPENDINGeven when the newly armed time-sync point has a valid live status; resolve and inspect the corresponding monitored point instead.Useful? React with 👍 / 👎.