diff --git a/MainWindow.SclQuickActions.cs b/MainWindow.SclQuickActions.cs index e943ea089..a712eb4e3 100644 --- a/MainWindow.SclQuickActions.cs +++ b/MainWindow.SclQuickActions.cs @@ -124,6 +124,16 @@ internal async Task OpenIedWorkspaceActionsAsync(Iec61850MonitorDevice device) return; } + // Unit metadata belongs to the same source-neutral Static DataSet workflow. + // Resolve only selected measurement owners on the active MMS session, before + // any RCB is armed, so discovery and opened-SCL paths expose the same verified + // engineering-unit behavior without disturbing active reporting. + SetStatus($"{device.Name}: verifying engineering units for selected Static DataSet measurements…"); + await _runtime.EnrichSelectedStaticDataSetUnitsAsync( + device, + _applicationCancellation.Token); + device.RefreshComputed(); + if (!device.IsMonitoring) await StartDeviceMonitorAsync(device); return; diff --git a/MainWindow.xaml b/MainWindow.xaml index bea194a8d..9a2d0d301 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -11,18 +11,26 @@ TextOptions.TextHintingMode="Fixed" Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown"> - + - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + - - - - - - - - - + + - - + + + + + + + + + + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 69eae95b6..4ee6894db 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -276,7 +276,13 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) var connected = firstImported.IsConnected || await ConnectUsingSavedModelAsync(firstImported); if (connected && !firstImported.IsMonitoring) + { + await _runtime.EnrichSelectedStaticDataSetUnitsAsync( + firstImported, + _applicationCancellation.Token); + firstImported.RefreshComputed(); await StartDeviceMonitorAsync(firstImported); + } } } else @@ -763,12 +769,20 @@ private async Task ConnectAndConfigureDeviceAsync( device.IsBusy = false; device.RefreshComputed(); - if (openWizard && device.SignalCount > 0) + var discoveredDataSetCount = device.LiveDiscoveryModel?.DataSets.Count ?? 0; + if (openWizard && (device.SignalCount > 0 || discoveredDataSetCount > 0)) { if ((selectDevice || ReferenceEquals(SelectedDevice, device)) && !_signalSelectionWizardOpen) - await OpenSignalSelectionWizardAsync(device, restoredCount); + { + // Discovery and Open SCL converge here. Once a canonical model exists, + // both sources present the same task-first IED Actions workflow so Static + // DataSet acquisition is source-neutral. + await OpenIedWorkspaceActionsAsync(device); + } else + { SetStatus($"{device.Name}: discovery complete. Use the edit icon on its IED card to review {restoredCount} restored selection(s)."); + } } return true; } @@ -1559,13 +1573,27 @@ private async void IedSaveScl_Click(object sender, RoutedEventArgs e) { if (device.SclWorkspace == null && device.IsConnected) { - SetStatus($"{device.Name}: enriching Save SCL with bounded live FC-root values…"); - var instanceEvidence = await _runtime - .EnrichCanonicalForSclSaveAsync(device, _applicationCancellation.Token); - AddLog( - "INFO", - "SCL Export", - $"{device.Name}: save-time enrichment completed with {instanceEvidence:N0} exact instance-value leaf/leaves. Fast discovery remained unchanged."); + if (device.IsMonitoring) + { + // Save is a snapshot/export operation, not an acquisition-mode change. + // Never tear down an armed report session merely to collect optional + // instance evidence. The canonical discovery/model already bound + // to this accepted MMS association is sufficient for interoperable SCL. + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: monitoring remains active; Save SCL uses the current canonical model and skips optional save-time value enrichment."); + } + else + { + SetStatus($"{device.Name}: enriching Save SCL with bounded live FC-root values…"); + var instanceEvidence = await _runtime + .EnrichCanonicalForSclSaveAsync(device, _applicationCancellation.Token); + AddLog( + "INFO", + "SCL Export", + $"{device.Name}: save-time enrichment completed with {instanceEvidence:N0} exact instance-value leaf/leaves. Fast discovery remained unchanged."); + } } if (device.SclWorkspace != null && diff --git a/Models/Iec61850ValueStatePresentation.cs b/Models/Iec61850ValueStatePresentation.cs index 5ddf59cf9..dba01b57a 100644 --- a/Models/Iec61850ValueStatePresentation.cs +++ b/Models/Iec61850ValueStatePresentation.cs @@ -37,9 +37,11 @@ public static string Classify(string? value, string? dataType = null) if (normalized.Contains("open", StringComparison.Ordinal) || HasStateCode(normalized, "01")) return Inactive; - if (normalized is "true" or "on" or "active" or "asserted" or "energized") + if (normalized.StartsWith("true", StringComparison.Ordinal) || + normalized is "on" or "active" or "asserted" or "energized") return Active; - if (normalized is "false" or "off" or "inactive" or "deasserted" or "deenergized") + if (normalized.StartsWith("false", StringComparison.Ordinal) || + normalized is "off" or "inactive" or "deasserted" or "deenergized") return Inactive; // Bare 0/1 can be an analog value, counter, set point, or enum. Only treat it @@ -62,6 +64,188 @@ private static bool IsBooleanType(string? dataType) return type is "bool" or "boolean" or "sps" or "singlepointstatus" || type.Contains("boolean", StringComparison.Ordinal); } + + /// + /// Compact operator badge derived only from the declared IEC/MMS data type. + /// Unlike ValueVisualKind this never infers a type from the rendered value, signal + /// name, category, or numeric shape. Unknown metadata intentionally yields no badge. + /// + public static string TypeToken(string? dataType) + { + var type = (dataType ?? string.Empty).Trim(); + if (type.Length == 0) + return string.Empty; + + var normalized = type + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal) + .ToUpperInvariant(); + + if (normalized is "DBPOS" or "DPC" or "DPS" or "DOUBLEPOINTSTATUS") + return "DP"; + + if (normalized is "BOOL" or "BOOLEAN" or "SPS" or "SPC" or "SINGLEPOINTSTATUS") + return "B"; + + if (normalized.StartsWith("FLOAT", StringComparison.Ordinal) || + normalized is "REAL" or "REAL32" or "REAL64" or "DOUBLE") + { + return "F"; + } + + if (normalized.StartsWith("UINT", StringComparison.Ordinal) || + normalized is "INT8U" or "INT16U" or "INT24U" or "INT32U" or "INT64U") + { + return "U"; + } + + if (normalized.StartsWith("INT", StringComparison.Ordinal) || + normalized is "INTEGER" or "SIGNED") + { + return "I"; + } + + if (normalized.StartsWith("ENUM", StringComparison.Ordinal) || + normalized is "ENUMERATED") + { + return "E"; + } + + return string.Empty; + } + + public const string Analog = "Analog"; + public const string BooleanTrue = "BooleanTrue"; + public const string BooleanFalse = "BooleanFalse"; + public const string PositionOpen = "PositionOpen"; + public const string PositionClose = "PositionClose"; + public const string PositionIntermediate = "PositionIntermediate"; + public const string PositionBad = "PositionBad"; + + /// + /// Operator-facing visual family for process values. This is intentionally more + /// specific than ValueTone: it differentiates analog, Boolean and DPC/position + /// semantics without changing process truth, alarm severity or quality. + /// + public static string ClassifyVisualKind( + string? value, + string? dataType = null, + string? category = null, + string? reference = null) + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-" || + text.Equals("Pending", StringComparison.OrdinalIgnoreCase) || + text.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return Neutral; + } + + var normalized = text.ToLowerInvariant(); + + if (IsPositionSemantic(dataType, category, reference)) + { + if (normalized.Contains("intermediate", StringComparison.Ordinal) || HasStateCode(normalized, "00")) + return PositionIntermediate; + if (normalized.Contains("bad", StringComparison.Ordinal) || HasStateCode(normalized, "11")) + return PositionBad; + if (normalized.Contains("open", StringComparison.Ordinal) || HasStateCode(normalized, "01")) + return PositionOpen; + if (normalized.Contains("close", StringComparison.Ordinal) || HasStateCode(normalized, "10")) + return PositionClose; + } + + // Explicit True/False presentation is already semantic evidence from the report + // formatter and remains safe even when discovery metadata is less specific. + if (normalized.StartsWith("true", StringComparison.Ordinal)) + return BooleanTrue; + if (normalized.StartsWith("false", StringComparison.Ordinal)) + return BooleanFalse; + + // ON/OFF and bare 0/1 are ambiguous outside a proven Boolean/SPS type. Do not + // reclassify a generic Enum merely because its text happens to use those words. + if (IsBooleanType(dataType)) + { + if (normalized is "on" or "active" or "asserted" or "energized" or "1" or "1.0") + return BooleanTrue; + + if (normalized is "off" or "inactive" or "deasserted" or "deenergized" or "0" or "0.0") + return BooleanFalse; + } + + if (IsAnalogSemantic(text, dataType, category)) + return Analog; + + return Neutral; + } + + private static bool IsPositionSemantic(string? dataType, string? category, string? reference) + { + var type = (dataType ?? string.Empty).Trim(); + if (type.Equals("Dbpos", StringComparison.OrdinalIgnoreCase) || + type.Equals("DPC", StringComparison.OrdinalIgnoreCase) || + type.Equals("DoublePointStatus", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if ((category ?? string.Empty).Trim().Equals("Position", StringComparison.OrdinalIgnoreCase)) + return true; + + var normalizedReference = (reference ?? string.Empty) + .Trim() + .Replace((char)36, '.') + .ToLowerInvariant(); + + return normalizedReference.Contains(".pos.stval", StringComparison.Ordinal) || + normalizedReference.EndsWith(".pos", StringComparison.Ordinal); + } + + private static bool IsAnalogSemantic(string text, string? dataType, string? category) + { + if ((category ?? string.Empty).Trim().Equals("Measurement", StringComparison.OrdinalIgnoreCase)) + return true; + + var type = (dataType ?? string.Empty).Trim().ToLowerInvariant(); + if (type.Contains("float", StringComparison.Ordinal) || + type.Contains("double", StringComparison.Ordinal) || + type.Contains("decimal", StringComparison.Ordinal) || + type.Contains("real", StringComparison.Ordinal) || + type.Contains("analog", StringComparison.Ordinal)) + { + return true; + } + + return LooksLikeAnalogAggregate(text); + } + + private static bool LooksLikeAnalogAggregate(string text) + { + var parts = text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) + return false; + + var numericMembers = 0; + foreach (var part in parts) + { + var separator = part.IndexOf('='); + if (separator <= 0 || separator + 1 >= part.Length) + return false; + + var number = part[(separator + 1)..].Trim(); + if (double.TryParse( + number, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out _)) + { + numericMembers++; + } + } + + return numericMembers == parts.Length; + } } /// diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index e37fc187e..82325a77e 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -640,11 +640,18 @@ public string Value { Raise(nameof(DisplayValue)); Raise(nameof(ValueTone)); + Raise(nameof(ValueVisualKind)); } } } public string DisplayValue => Value; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); public string ValueTone => Iec61850ValueStatePresentation.Classify(Value, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + Value, + IecDataType, + Category, + IecReference); public string Quality { get => _quality; set => Set(ref _quality, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } public string DeviceTimestamp { get => _deviceTimestamp; set => Set(ref _deviceTimestamp, string.IsNullOrWhiteSpace(value) ? "-" : value); } public string SourceMode { get => _sourceMode; set => Set(ref _sourceMode, string.IsNullOrWhiteSpace(value) ? "Unknown" : value); } @@ -799,7 +806,13 @@ public string EdgeType public string ChangeText => $"{EdgeType} · {OldValue} → {NewValue}"; public string EventValue => string.IsNullOrWhiteSpace(NewValue) ? "-" : NewValue; public string DisplayValue => EventValue; + public string ValueTypeToken => Iec61850ValueStatePresentation.TypeToken(IecDataType); public string ValueTone => Iec61850ValueStatePresentation.Classify(EventValue, IecDataType); + public string ValueVisualKind => Iec61850ValueStatePresentation.ClassifyVisualKind( + EventValue, + IecDataType, + string.Empty, + IecReference); public string QualityTone => Iec61850QualityPresentation.Classify(Quality); public string IecTelegram => Iec61850MonitorPoint.StripIedNamePrefix(IecReference, DeviceName); diff --git a/SclSignalSelectionModeWindow.xaml b/SclSignalSelectionModeWindow.xaml index f4668d997..1365a1f40 100644 --- a/SclSignalSelectionModeWindow.xaml +++ b/SclSignalSelectionModeWindow.xaml @@ -50,10 +50,11 @@