From a671bb54c95db3be9e5b5d6762f716b4dc2e3145 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 10:46:30 +0700 Subject: [PATCH 01/13] Use Smart Reporting for workbook-driven IO FAT acquisition --- MainWindow.IoTesting.AutoConnect.cs | 40 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 234abe1..b9ac7eb 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -9,9 +9,9 @@ public partial class MainWindow private readonly IoTestSignalSelectionService _ioTestSignalSelectionService = new(); /// - /// Prepares one imported IO-list IED for a read-only FAT session. The workbook + /// Prepares one imported IO-list IED for a monitoring-only FAT session. The workbook /// supplies the endpoint and exact signal scope, so the operator does not need to - /// duplicate the Add IED and signal-selection workflow in the engineering window. + /// duplicate Add IED and signal-selection work in the engineering window. /// internal async Task PrepareIoTestIedForFatAsync( IoTestProject project, @@ -40,9 +40,9 @@ internal async Task PrepareIoTestIedForFatAsync( IdentitySource = "IO List workbook", IpAddress = ied.IpAddress, Port = 102, - AllowDynamicDataSetWrites = false, + AllowDynamicDataSetWrites = true, Status = "IO FAT ready to connect", - Detail = "Connect & Start will discover the live model and monitor only the imported FAT scope." + Detail = "Connect & Start will discover the live model and arm report-first acquisition for the imported FAT scope." }; Devices.Add(device); RaiseWorkspaceCounts(); @@ -68,14 +68,16 @@ internal async Task PrepareIoTestIedForFatAsync( if (string.IsNullOrWhiteSpace(device.SclIedName)) device.SclIedName = ied.IedName; - // Dedicated IO FAT remains read-only toward IED configuration. Existing - // configured reports are preferred; uncovered points use visible bounded - // polling instead of creating a dynamic DataSet or writing an RCB. - device.AllowDynamicDataSetWrites = false; + // Monitoring-only toward the process: no control commands are executed. Use + // configured RCB/DataSet coverage first, then an association-scoped temporary + // dynamic DataSet/URCB when exact coverage is missing, with bounded MMS + // verification/fallback last. Temporary report resources are released when the + // native monitoring session stops. + device.AllowDynamicDataSetWrites = true; try { - progress?.Report($"Connecting to {ied.IedName} at {ied.IpAddress}:102…"); + progress?.Report($"Connecting {ied.IedName} · {ied.IpAddress}:102"); var usedSavedModel = false; if (!device.IsConnected) { @@ -88,12 +90,12 @@ internal async Task PrepareIoTestIedForFatAsync( { _ioTestLiveBindingService.Bind(project, Devices); return IoTestSessionActionResult.Failure( - $"ARSAS could not connect to {ied.IedName} at {ied.IpAddress}:102. Open Diagnostics for the exact MMS association or discovery error."); + $"ARSAS could not connect to {ied.IedName} at {ied.IpAddress}:102. Open Diagnostics for the MMS association or discovery error."); } } else if (device.Signals.Count == 0) { - progress?.Report($"{ied.IedName} is connected but has no discovered model. Running full live discovery…"); + progress?.Report($"{ied.IedName} connected · discovering live model"); await StopDeviceConnectionAsync(device); if (!await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false)) { @@ -102,11 +104,11 @@ internal async Task PrepareIoTestIedForFatAsync( } } - progress?.Report($"Matching {requestedPoints.Count} imported signal(s) to the discovered IED model…"); + progress?.Report($"Matching {requestedPoints.Count} workbook signal(s)"); var selection = _ioTestSignalSelectionService.Resolve(ied, device); if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery) { - progress?.Report("The saved/loaded model does not contain every imported signal. Refreshing the live model once…"); + progress?.Report("Refreshing live model once · saved model missed workbook points"); if (device.IsMonitoring) await StopDeviceMonitorAsync(device); if (device.IsConnected) @@ -147,19 +149,18 @@ internal async Task PrepareIoTestIedForFatAsync( if (device.IsMonitoring && (selectionChanged || !allRequestedPointsLive)) { - progress?.Report("Refreshing monitoring so every imported signal is included…"); + progress?.Report("Refreshing acquisition for the workbook scope"); await StopDeviceMonitorAsync(device); } if (!device.IsMonitoring) { - progress?.Report( - "Starting configured-report-first monitoring. Uncovered signals will use visible bounded polling fallback…"); + progress?.Report("Arming configured RCB · dynamic URCB fallback if coverage is missing"); if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) { _ioTestLiveBindingService.Bind(project, Devices); return IoTestSessionActionResult.Failure( - $"{ied.IedName} connected, but ARSAS could not start live monitoring for the imported FAT scope."); + $"{ied.IedName} connected, but ARSAS could not start live acquisition for the imported FAT scope."); } } @@ -178,13 +179,12 @@ internal async Task PrepareIoTestIedForFatAsync( SaveSignalSelectionMemory(device); var modelText = usedSavedModel ? "saved model" : "live model"; - var message = - $"{ied.IedName} is connected from the {modelText}; {liveCount}/{requestedPoints.Count} imported signal(s) are live. {device.AcquisitionMode}"; + var message = $"{ied.IedName} · {liveCount}/{requestedPoints.Count} live · {device.AcquisitionMode}"; SetStatus(message); AddLog( "INFO", "IO Testing", - $"{message} Dynamic DataSet/RCB writes are disabled for the dedicated FAT workflow. Project live-bound={binding.LivePointCount}."); + $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}."); progress?.Report(message); return IoTestSessionActionResult.Success(message); } From 653cd1e477f567ea92f0eb9df03d685aeef7cfc8 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 10:47:53 +0700 Subject: [PATCH 02/13] Project live acquisition state and relay timestamp into IO FAT rows --- Services/IoTesting/IoTestLiveBindingService.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index c8cf400..0bd79c0 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -31,7 +31,7 @@ public IoTestLiveBindingSummary Bind( var device = FindDevice(iedPlan, deviceList); if (device == null) { - iedPlan.ApplyLiveDeviceBinding(null, "IED is present in the IO list but is not loaded in the engineering workspace"); + iedPlan.ApplyLiveDeviceBinding(null, "Not connected"); foreach (var point in iedPlan.TestPoints) { point.ApplyLiveBinding( @@ -45,10 +45,12 @@ public IoTestLiveBindingSummary Bind( iedPlan.ApplyLiveDeviceBinding( device.DeviceId, device.IsMonitoring - ? $"Monitoring · {device.Name} · {device.EndpointText}" + ? $"Monitoring · {device.AcquisitionMode}" : device.IsConnected - ? $"Connected · {device.Name} · {device.EndpointText}" - : $"Loaded · {device.Name} · {device.EndpointText}"); + ? "Connected · preparing live acquisition" + : "Workspace model ready", + device.IsConnected, + device.IsMonitoring); foreach (var point in iedPlan.TestPoints) { @@ -64,6 +66,9 @@ public IoTestLiveBindingSummary Bind( point.Runtime.CurrentValue = binding.LivePoint.Value; point.Runtime.CurrentQuality = binding.LivePoint.Quality; point.Runtime.CurrentSource = binding.LivePoint.SourceMode; + point.Runtime.CurrentIedTimestamp = string.IsNullOrWhiteSpace(binding.LivePoint.DeviceTimestamp) || binding.LivePoint.DeviceTimestamp == "-" + ? "—" + : binding.LivePoint.DeviceTimestamp; } } else if (binding.State == IoTestLiveBindingState.SignalNotFound) From eb87606e40ed8fa8dc89ae9410762aaf04fa5121 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 10:49:05 +0700 Subject: [PATCH 03/13] Require relay timestamps for accepted IO FAT transitions --- Services/IoTesting/IoTestTransitionEvaluator.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoTestTransitionEvaluator.cs b/Services/IoTesting/IoTestTransitionEvaluator.cs index e1d1d02..0a7ee5b 100644 --- a/Services/IoTesting/IoTestTransitionEvaluator.cs +++ b/Services/IoTesting/IoTestTransitionEvaluator.cs @@ -248,7 +248,7 @@ private static IoTestTransitionEvidence CreateEvidence( bool? previous, IoTestObservation observation) { - var (verdict, reason) = EvaluateQuality(observation.Quality); + var (verdict, reason) = EvaluateEvidenceObservation(observation); return new IoTestTransitionEvidence( Guid.NewGuid(), transition, @@ -265,6 +265,17 @@ private static IoTestTransitionEvidence CreateEvidence( reason); } + private static (IoEvidenceVerdict Verdict, string Reason) EvaluateEvidenceObservation(IoTestObservation observation) + { + var (qualityVerdict, qualityReason) = EvaluateQuality(observation.Quality); + if (observation.IedTimestamp != null || qualityVerdict == IoEvidenceVerdict.Rejected) + return (qualityVerdict, qualityReason); + + return qualityVerdict == IoEvidenceVerdict.Accepted + ? (IoEvidenceVerdict.Review, "Relay timestamp was not supplied for this transition") + : (IoEvidenceVerdict.Review, $"{qualityReason}; relay timestamp was not supplied"); + } + internal static (IoEvidenceVerdict Verdict, string Reason) EvaluateQuality(string? quality) { var normalized = (quality ?? string.Empty).Trim().ToLowerInvariant(); From dc19af387544fc8cf2a892b80d67bd1327e591d5 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 10:50:26 +0700 Subject: [PATCH 04/13] Add per-IED acquisition state and relay timestamp presentation --- Models/IoTesting/IoTestModels.cs | 90 +++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 2f6030e..1f43d4e 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -1,6 +1,8 @@ using ArIED61850Tester.Models; using System.ComponentModel; +using System.Globalization; +using System.Text.Json.Serialization; namespace ArIED61850Tester.Models.IoTesting; @@ -78,6 +80,7 @@ public sealed class IoTestPointRuntime : ObservableObject private string _currentValue = "-"; private string _currentQuality = "Unknown"; private string _currentSource = "Not connected"; + private string _currentIedTimestamp = "—"; public IoTestPointState State { @@ -85,7 +88,10 @@ public IoTestPointState State internal set { if (Set(ref _state, value)) + { Raise(nameof(IsComplete)); + Raise(nameof(StateText)); + } } } @@ -99,14 +105,29 @@ internal set public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); } public string CurrentQuality { get => _currentQuality; internal set => Set(ref _currentQuality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } public string CurrentSource { get => _currentSource; internal set => Set(ref _currentSource, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } + public string CurrentIedTimestamp { get => _currentIedTimestamp; internal set => Set(ref _currentIedTimestamp, string.IsNullOrWhiteSpace(value) ? "—" : value); } public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; + public string StateText => State switch + { + IoTestPointState.NotStarted => "Not started", + IoTestPointState.WaitingForBaseline => "Waiting baseline", + IoTestPointState.WaitingForOffBaseline => "Waiting OFF baseline", + IoTestPointState.ArmedForOn => "Ready for ON", + IoTestPointState.OnCaptured => "ON captured", + IoTestPointState.Passed => "PASS", + IoTestPointState.Review => "REVIEW", + IoTestPointState.Failed => "FAIL", + _ => State.ToString() + }; + internal void ApplyObservation(IoTestObservation observation) { CurrentValue = observation.RawValue; CurrentQuality = observation.Quality; CurrentSource = observation.AcquisitionSource; + CurrentIedTimestamp = observation.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "—"; } internal void ResetAttempt() @@ -199,6 +220,10 @@ public sealed class IoTestIedPlan : ObservableObject { private string _liveDeviceId = string.Empty; private string _liveStatusText = "Not evaluated"; + private bool _isPreparing; + private string _preparationStatusText = string.Empty; + private bool _isLiveConnected; + private bool _isLiveMonitoring; private bool _notificationsInitialized; public required string IedName { get; init; } @@ -211,19 +236,82 @@ public sealed class IoTestIedPlan : ObservableObject 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); } + + [JsonIgnore] + public bool IsPreparing + { + get => _isPreparing; + private set + { + if (!Set(ref _isPreparing, value)) + return; + Raise(nameof(CardStateText)); + } + } + + [JsonIgnore] + public string PreparationStatusText + { + get => _preparationStatusText; + private set => Set(ref _preparationStatusText, value?.Trim() ?? string.Empty); + } + + [JsonIgnore] + public bool IsLiveConnected + { + get => _isLiveConnected; + private set + { + if (!Set(ref _isLiveConnected, value)) + return; + Raise(nameof(CardStateText)); + } + } + + [JsonIgnore] + public bool IsLiveMonitoring + { + get => _isLiveMonitoring; + private set + { + if (!Set(ref _isLiveMonitoring, value)) + return; + Raise(nameof(CardStateText)); + } + } + + [JsonIgnore] + public string CardStateText => IsPreparing ? "CONNECTING" : IsLiveMonitoring ? "LIVE" : IsLiveConnected ? "READY" : "OFFLINE"; + public int EnabledCount => TestPoints.Count(point => point.TestEnabled); public int PassedCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Passed); public int ReviewCount => TestPoints.Count(point => point.Runtime.State == IoTestPointState.Review); public int BoundCount => TestPoints.Count(point => point.IsLiveBound); public int PendingCount => Math.Max(0, EnabledCount - PassedCount - ReviewCount); - public void ApplyLiveDeviceBinding(string? deviceId, string status) + public void ApplyLiveDeviceBinding( + string? deviceId, + string status, + bool isConnected = false, + bool isMonitoring = false) { LiveDeviceId = deviceId ?? string.Empty; LiveStatusText = status; + IsLiveConnected = isConnected; + IsLiveMonitoring = isMonitoring; RaiseProgressProperties(); } + public void SetPreparationState(bool isPreparing, string? status = null) + { + IsPreparing = isPreparing; + if (status != null) + PreparationStatusText = status; + if (!isPreparing && string.IsNullOrWhiteSpace(status)) + PreparationStatusText = string.Empty; + Raise(nameof(CardStateText)); + } + public void InitializeRuntimeNotifications() { if (_notificationsInitialized) From 09022313cbd5319b75f650989498bc2ec9c719c6 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 11:20:55 +0700 Subject: [PATCH 05/13] Keep IO FAT preparation non-modal while reporting arms --- MainWindow.IoTesting.AutoConnect.cs | 50 +++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index b9ac7eb..81eb271 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -27,6 +27,13 @@ internal async Task PrepareIoTestIedForFatAsync( if (requestedPoints.Count == 0) return IoTestSessionActionResult.Failure("No import-ready IO-list signal is enabled for this IED."); + void ReportProgress(string message) + { + ied.SetPreparationState(true, message); + progress?.Report(message); + } + + ied.SetPreparationState(true, $"Connecting {ied.IedName} · {ied.IpAddress}:102"); var device = ResolveIoTestDevice(ied.LiveDeviceId) ?? ResolveIoTestDevice(ied.IpAddress) ?? ResolveIoTestDevice(ied.IedName); @@ -49,12 +56,16 @@ internal async Task PrepareIoTestIedForFatAsync( } if (device.IsBusy) + { + ied.SetPreparationState(false, "IED is busy in another connection workflow"); return IoTestSessionActionResult.Failure($"{ied.IedName} is already busy with another connection or discovery workflow."); + } if (!device.IpAddress.Equals(ied.IpAddress, StringComparison.OrdinalIgnoreCase)) { if (device.IsConnected || device.IsMonitoring) { + ied.SetPreparationState(false, "Endpoint mismatch"); return IoTestSessionActionResult.Failure( $"The loaded {ied.IedName} workspace is connected to {device.IpAddress}, but the IO list requires {ied.IpAddress}. Stop that engineering session or correct the workbook endpoint before FAT."); } @@ -77,7 +88,7 @@ internal async Task PrepareIoTestIedForFatAsync( try { - progress?.Report($"Connecting {ied.IedName} · {ied.IpAddress}:102"); + ReportProgress($"Connecting {ied.IedName} · {ied.IpAddress}:102"); var usedSavedModel = false; if (!device.IsConnected) { @@ -95,7 +106,7 @@ internal async Task PrepareIoTestIedForFatAsync( } else if (device.Signals.Count == 0) { - progress?.Report($"{ied.IedName} connected · discovering live model"); + ReportProgress($"{ied.IedName} connected · discovering live model"); await StopDeviceConnectionAsync(device); if (!await ConnectAndConfigureDeviceAsync(device, openWizard: false, selectDevice: false)) { @@ -104,11 +115,11 @@ internal async Task PrepareIoTestIedForFatAsync( } } - progress?.Report($"Matching {requestedPoints.Count} workbook signal(s)"); + ReportProgress($"Matching {requestedPoints.Count} workbook signal(s)"); var selection = _ioTestSignalSelectionService.Resolve(ied, device); if (!selection.Succeeded && selection.CanRetryWithFreshDiscovery) { - progress?.Report("Refreshing live model once · saved model missed workbook points"); + ReportProgress("Refreshing live model once · saved model missed workbook points"); if (device.IsMonitoring) await StopDeviceMonitorAsync(device); if (device.IsConnected) @@ -149,13 +160,13 @@ internal async Task PrepareIoTestIedForFatAsync( if (device.IsMonitoring && (selectionChanged || !allRequestedPointsLive)) { - progress?.Report("Refreshing acquisition for the workbook scope"); + ReportProgress("Refreshing acquisition for the workbook scope"); await StopDeviceMonitorAsync(device); } if (!device.IsMonitoring) { - progress?.Report("Arming configured RCB · dynamic URCB fallback if coverage is missing"); + ReportProgress("Arming configured RCB · dynamic URCB fallback if coverage is missing"); if (!await StartDeviceMonitorAsync(device, navigateToExplorer: false)) { _ioTestLiveBindingService.Bind(project, Devices); @@ -164,6 +175,8 @@ internal async Task PrepareIoTestIedForFatAsync( } } + await WaitForIoFatAcquisitionAsync(device, ReportProgress); + var binding = _ioTestLiveBindingService.Bind(project, Devices); var liveCount = requestedPoints.Count(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); @@ -185,7 +198,7 @@ internal async Task PrepareIoTestIedForFatAsync( "INFO", "IO Testing", $"{message}. Acquisition policy: configured RCB → temporary dynamic DataSet/URCB → bounded MMS verification/fallback. No process control commands are enabled. Project live-bound={binding.LivePointCount}; model={modelText}."); - progress?.Report(message); + ReportProgress(message); return IoTestSessionActionResult.Success(message); } catch (OperationCanceledException) @@ -203,8 +216,31 @@ internal async Task PrepareIoTestIedForFatAsync( } finally { + ied.SetPreparationState(false, ied.LiveStatusText); if (createdFromWorkbook) RaiseWorkspaceCounts(); } } + + private static async Task WaitForIoFatAcquisitionAsync( + Iec61850MonitorDevice device, + Action reportProgress) + { + if (!device.IsMonitoring) + return; + + for (var attempt = 0; attempt < 35; attempt++) + { + var mode = device.AcquisitionMode ?? string.Empty; + if (!mode.Contains("arming", StringComparison.OrdinalIgnoreCase) && + !mode.Contains("live start", StringComparison.OrdinalIgnoreCase)) + return; + + if (attempt == 0) + reportProgress("Validating RCB/DataSet coverage · MMS remains verification only"); + await Task.Delay(100); + } + + reportProgress($"Acquisition still settling · {device.AcquisitionMode}"); + } } From 43cbe26b69801d7f325bd7ff2e3a5b9a84741f6e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 11:22:05 +0700 Subject: [PATCH 06/13] Expose relay timestamps and per-IED preparation state --- Models/IoTesting/IoTestModels.cs | 61 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index 1f43d4e..92afeea 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -98,22 +98,50 @@ internal set public bool? LastObservedState { get => _lastObservedState; internal set => Set(ref _lastObservedState, value); } public long LastSequence { get => _lastSequence; internal set => Set(ref _lastSequence, value); } public long ConnectionGeneration { get => _connectionGeneration; internal set => Set(ref _connectionGeneration, value); } - public IoTestTransitionEvidence? OnEvidence { get => _onEvidence; internal set => Set(ref _onEvidence, value); } - public IoTestTransitionEvidence? OffEvidence { get => _offEvidence; internal set => Set(ref _offEvidence, value); } + public IoTestTransitionEvidence? OnEvidence + { + get => _onEvidence; + internal set + { + if (!Set(ref _onEvidence, value)) + return; + Raise(nameof(OnRelayTimestampText)); + Raise(nameof(OnEvidenceToolTip)); + } + } + + public IoTestTransitionEvidence? OffEvidence + { + get => _offEvidence; + internal set + { + if (!Set(ref _offEvidence, value)) + return; + Raise(nameof(OffRelayTimestampText)); + Raise(nameof(OffEvidenceToolTip)); + } + } public string StatusReason { get => _statusReason; internal set => Set(ref _statusReason, value ?? string.Empty); } public int Attempt { get => _attempt; internal set => Set(ref _attempt, value); } public string CurrentValue { get => _currentValue; internal set => Set(ref _currentValue, string.IsNullOrWhiteSpace(value) ? "-" : value); } public string CurrentQuality { get => _currentQuality; internal set => Set(ref _currentQuality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } public string CurrentSource { get => _currentSource; internal set => Set(ref _currentSource, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } - public string CurrentIedTimestamp { get => _currentIedTimestamp; internal set => Set(ref _currentIedTimestamp, string.IsNullOrWhiteSpace(value) ? "—" : value); } + + [JsonIgnore] + public string CurrentIedTimestamp + { + get => _currentIedTimestamp; + internal set => Set(ref _currentIedTimestamp, string.IsNullOrWhiteSpace(value) ? "—" : value); + } public bool IsComplete => State is IoTestPointState.Passed or IoTestPointState.Review or IoTestPointState.Failed; + [JsonIgnore] public string StateText => State switch { IoTestPointState.NotStarted => "Not started", IoTestPointState.WaitingForBaseline => "Waiting baseline", - IoTestPointState.WaitingForOffBaseline => "Waiting OFF baseline", + IoTestPointState.WaitingForOffBaseline => "Waiting OFF", IoTestPointState.ArmedForOn => "Ready for ON", IoTestPointState.OnCaptured => "ON captured", IoTestPointState.Passed => "PASS", @@ -122,6 +150,18 @@ internal set _ => State.ToString() }; + [JsonIgnore] + public string OnRelayTimestampText => FormatRelayTimestamp(OnEvidence?.IedTimestamp); + + [JsonIgnore] + public string OffRelayTimestampText => FormatRelayTimestamp(OffEvidence?.IedTimestamp); + + [JsonIgnore] + public string OnEvidenceToolTip => BuildEvidenceToolTip(OnEvidence, "ON"); + + [JsonIgnore] + public string OffEvidenceToolTip => BuildEvidenceToolTip(OffEvidence, "OFF"); + internal void ApplyObservation(IoTestObservation observation) { CurrentValue = observation.RawValue; @@ -130,6 +170,19 @@ internal void ApplyObservation(IoTestObservation observation) CurrentIedTimestamp = observation.IedTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture) ?? "—"; } + private static string FormatRelayTimestamp(DateTimeOffset? value) + => value?.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) ?? "—"; + + private static string BuildEvidenceToolTip(IoTestTransitionEvidence? evidence, string label) + { + if (evidence == null) + return $"{label} transition has not been captured."; + + var relay = evidence.IedTimestamp?.ToString("O", CultureInfo.InvariantCulture) ?? "not supplied"; + var captured = evidence.CapturedAt.ToString("O", CultureInfo.InvariantCulture); + return $"Relay timestamp: {relay}\nARSAS capture: {captured}\nQuality: {evidence.Quality}\nSource: {evidence.AcquisitionSource}\n{evidence.Verdict}: {evidence.VerdictReason}"; + } + internal void ResetAttempt() { State = IoTestPointState.WaitingForBaseline; From 577efa934965ca6c0deb2a5e319d2962c07e1589 Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 11:22:50 +0700 Subject: [PATCH 07/13] Project live relay acquisition details into IO FAT From 892e6ba3c81f695dbffb5eb266f8dd1ff097dd1b Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 11:23:46 +0700 Subject: [PATCH 08/13] Require relay timestamps for accepted FAT transitions From f2b8614e61bc00ca1093ea8eb3a30beb620efd4e Mon Sep 17 00:00:00 2001 From: masarray Date: Wed, 29 Jul 2026 11:27:18 +0700 Subject: [PATCH 09/13] Redesign IO FAT workspace for calm non-modal operation --- IoListTestingWindow.xaml | 295 +++++++++++++++++---------------------- 1 file changed, 130 insertions(+), 165 deletions(-) diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml index 52e7c5e..055bc12 100644 --- a/IoListTestingWindow.xaml +++ b/IoListTestingWindow.xaml @@ -1,216 +1,181 @@ - + + + + + + + + - - - - - + - + - - - - - - - - + + + + + - - - -