Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bb1d891
feat(fat): add canonical IEC 61850 reference matcher
masarray Aug 12, 2026
515c7d8
feat(fat): resolve IEC signals with conservative smart matching
masarray Aug 12, 2026
f137f4e
feat(fat): bind live points through smart IEC reference matcher
masarray Aug 12, 2026
db843c9
test(fat): cover smart IEC reference matching
masarray Aug 12, 2026
aca1833
feat(fat): add supplemental evidence metadata to journal entries
masarray Aug 12, 2026
5f3b7fd
feat(fat): add COMTRADE and time-sync evidence journal service
masarray Aug 12, 2026
0d61399
feat(fat): arm explicit time-sync status during FAT monitoring
masarray Aug 12, 2026
9f1ff24
feat(fat): expose COMTRADE and time-sync evidence in FAT window
masarray Aug 12, 2026
b8165b9
feat(fat): auto-capture supplemental IED evidence in FAT workflow
masarray Aug 12, 2026
e3b3e21
fix(fat): harden supplemental evidence parsing and hashing
masarray Aug 12, 2026
1098ea3
test(fat): keep smart-match fixtures on one IED identity
masarray Aug 12, 2026
bd3a453
fix(fat): correct evidence button padding
masarray Aug 12, 2026
8c6d5e9
fix(fat): support ordinal char prefix checks
masarray Aug 12, 2026
1753de7
Persist latest remote COMTRADE FAT evidence per IED
masarray Aug 12, 2026
1884ea9
Capture remote COMTRADE listings as FAT evidence
masarray Aug 12, 2026
fa761a1
Treat remote COMTRADE listing as FAT file-service evidence
masarray Aug 12, 2026
7e278ea
Add COMTRADE file-service evidence to FAT reports
masarray Aug 12, 2026
eec709c
Include file-service COMTRADE evidence in FAT report layout
masarray Aug 12, 2026
655ec83
Test latest remote COMTRADE FAT evidence and report output
masarray Aug 12, 2026
46be2dc
Persist remote COMTRADE evidence in FAT snapshots and handover report
masarray Aug 12, 2026
599d640
Test remote COMTRADE evidence survives FAT workspace restart
masarray Aug 12, 2026
13883e1
Restore IED-level COMTRADE evidence during FAT bootstrap
masarray Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions IoListTestingWindow.ContextUx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,24 @@ 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);

private void IoListTestingWindow_ContentRendered(object? sender, EventArgs e)
{
Dispatcher.BeginInvoke(
new Action(InstallSelectedIedContext),
new Action(() =>
{
InstallSelectedIedContext();
InstallSupplementalEvidenceControls();
RefreshSupplementalEvidenceControls();
}),
DispatcherPriority.ContextIdle);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
228 changes: 228 additions & 0 deletions IoListTestingWindow.SupplementalEvidence.cs
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);
Comment on lines +114 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for the monitored time-sync point instead of the signal

On the saved-model fast-reconnect path, cached SignalDefinition instances start with Value = "-", while monitoring publishes live values into device.Points and does not copy them back into the signal. This loop therefore waits on a value that never changes and CaptureTimeSync subsequently reads the same stale signal, recording PENDING even when the newly armed time-sync point has a valid live status; resolve and inspect the corresponding monitored point instead.

Useful? React with 👍 / 👎.

}

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);
}
}
14 changes: 13 additions & 1 deletion MainWindow.IoTesting.AutoConnect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 16 additions & 1 deletion Models/IoTesting/IoTestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,21 @@ public sealed class IoTestIedPlan : ObservableObject
public string Switchgear { get; init; } = string.Empty;
public List<IoTestPointPlan> 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); }

Expand Down Expand Up @@ -460,4 +475,4 @@ public void InitializeRuntimeNotifications()
foreach (var ied in Ieds)
ied.InitializeRuntimeNotifications();
}
}
}
9 changes: 9 additions & 0 deletions Models/IoTesting/IoTestSessionModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the hash input for existing evidence journals

Adding these serialized properties invalidates every journal created by an earlier release: IoTestEvidenceJournal.Verify deserializes each old entry and then reserializes it to recompute the hash, at which point these initialized empty/default fields are emitted even though they were absent from the original hash input. Consequently, opening or exporting a workspace with pre-upgrade evidence reports a hash mismatch; the verifier needs a versioned/legacy hash representation rather than relying on defaults.

Useful? React with 👍 / 👎.

}

public sealed record IoTestJournalEnvelope(
Expand Down
5 changes: 3 additions & 2 deletions Services/IoTesting/IoFatPdfReportService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -55,4 +56,4 @@ public static void Save(string fileName, IoTestProject project, DateTimeOffset?
File.Delete(temporary);
}
}
}
}
Loading
Loading