From 9ebabf173ae5e38989e9b8f6c319c643f9904a67 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:06:08 +0700 Subject: [PATCH 01/21] chore: stage SCL integration patch 1 --- .integration/mainwindow-1.patch | 126 ++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .integration/mainwindow-1.patch diff --git a/.integration/mainwindow-1.patch b/.integration/mainwindow-1.patch new file mode 100644 index 00000000..67c6eeae --- /dev/null +++ b/.integration/mainwindow-1.patch @@ -0,0 +1,126 @@ +--- a/MainWindow.xaml.cs ++++ b/MainWindow.xaml.cs +@@ -11,6 +11,7 @@ + using System.Windows.Threading; + using System.Windows.Media; + using System.Windows.Media.Animation; ++using AR.Iec61850.Scl.Workspace; + using ArIED61850Tester.Models; + using ArIED61850Tester.Services; + using Microsoft.Win32; +@@ -20,6 +21,7 @@ + public partial class MainWindow : Window, INotifyPropertyChanged + { + private readonly Iec61850MonitorRuntime _runtime = new(); ++ private readonly SclWorkspaceService _sclWorkspaceService = new(); + private readonly CancellationTokenSource _applicationCancellation = new(); + private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _signalOwners = new(); +@@ -160,28 +162,18 @@ + return; + + var sourceName = Path.GetFileName(dialog.FileName); +- SetStatus($"Reading IED endpoints from {sourceName}…"); ++ SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…"); + try + { +- var result = await SclImportService.LoadAsync(dialog.FileName, _applicationCancellation.Token); +- foreach (var warning in result.Warnings.Take(25)) +- AddLog("WARN", "SCL", warning); +- if (result.Warnings.Count > 25) +- AddLog("WARN", "SCL", $"{result.Warnings.Count - 25} additional SCL warning(s) were omitted from the live log."); +- +- if (result.Endpoints.Count == 0) +- { +- var reason = result.ConnectedAccessPointCount == 0 +- ? "No ConnectedAP communication entries were found." +- : "ConnectedAP entries were found, but none contained a valid IP address."; +- SetStatus($"{sourceName}: no usable IEC 61850 MMS endpoints. {reason}"); +- AddLog("WARN", "SCL", $"{sourceName}: {reason}"); +- MessageBox.Show( +- this, +- $"No usable IEC 61850 MMS endpoint was found in {sourceName}.\n\n{reason}\n\nThe file may contain only an IED template without a Communication section.", +- "Open SCL", +- MessageBoxButton.OK, +- MessageBoxImage.Information); ++ var document = await _sclWorkspaceService.OpenAsync( ++ dialog.FileName, ++ cancellationToken: _applicationCancellation.Token); ++ LogSclFindings(sourceName, document.Findings); ++ ++ if (document.Ieds.Count == 0) ++ { ++ SetStatus($"{sourceName}: no IED model was found."); ++ AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace."); + return; + } + +@@ -190,51 +182,33 @@ + var retained = 0; + Iec61850MonitorDevice? firstImported = null; + +- foreach (var endpoint in result.Endpoints) ++ foreach (var workspace in document.Ieds) + { + var device = Devices.FirstOrDefault(item => +- item.IpAddress.Equals(endpoint.IpAddress, StringComparison.OrdinalIgnoreCase) && +- item.Port == endpoint.Port); +- ++ item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && ++ item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && ++ item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); ++ ++ if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) ++ { ++ retained++; ++ firstImported ??= device; ++ continue; ++ } ++ ++ var signals = SclWorkspaceSignalMapper.BuildSignals(workspace); + if (device == null) + { +- device = new Iec61850MonitorDevice +- { +- Name = endpoint.IedName, +- IdentitySource = $"SCL • {sourceName}", +- LogicalDeviceSummary = BuildSclEndpointSummary(endpoint), +- IpAddress = endpoint.IpAddress, +- Port = endpoint.Port, +- AllowDynamicDataSetWrites = true, +- Status = "SCL endpoint ready", +- Detail = $"Imported from {sourceName}. Press Play to connect and verify the live IEC 61850 model.", +- AcquisitionMode = "SCL • live discovery pending" +- }; ++ device = new Iec61850MonitorDevice(); + Devices.Add(device); + added++; + } +- else if (!device.IsConnected && !device.IsBusy && !device.HasDiscoveryCache) ++ else + { +- if (string.IsNullOrWhiteSpace(device.Name) || +- device.Name.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) +- { +- device.Name = endpoint.IedName; +- } +- device.IdentitySource = $"SCL • {sourceName}"; +- device.LogicalDeviceSummary = BuildSclEndpointSummary(endpoint); +- device.Status = "SCL endpoint ready"; +- device.Detail = $"Endpoint refreshed from {sourceName}. Press Play to connect and verify the live IEC 61850 model."; +- device.AcquisitionMode = "SCL • live discovery pending"; +- device.RefreshComputed(); + refreshed++; + } +- else +- { +- // Preserve active sessions and successful discovery caches. SCL is an +- // endpoint-import path, never authority over a verified live model. +- retained++; +- } +- ++ ++ ApplySclWorkspaceToDevice(device, document, workspace, signals); + firstImported ??= device; + } + From 85ded8d02ff30414f6dabaf3cbc8ba8b2e68d8ca Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:06:35 +0700 Subject: [PATCH 02/21] chore: stage SCL integration patch 2 --- .integration/mainwindow-2.patch | 148 ++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 .integration/mainwindow-2.patch diff --git a/.integration/mainwindow-2.patch b/.integration/mainwindow-2.patch new file mode 100644 index 00000000..6078743b --- /dev/null +++ b/.integration/mainwindow-2.patch @@ -0,0 +1,148 @@ +--- a/MainWindow.xaml.cs ++++ b/MainWindow.xaml.cs +@@ -244,39 +218,130 @@ + UpdateNavigationVisuals(0, animate: true); + RaiseWorkspaceCounts(); + +- var warningText = result.Warnings.Count == 0 ? string.Empty : $", {result.Warnings.Count} warning(s)"; +- var status = $"{sourceName}: {result.Endpoints.Count} SCL endpoint(s) read — {added} added, {refreshed} refreshed, {retained} existing retained{warningText}. Use Play or Connect All for live verification."; ++ var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline); ++ var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding); ++ var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained."; + SetStatus(status); + AddLog("INFO", "SCL", status); + } + catch (OperationCanceledException) + { +- SetStatus($"{sourceName}: SCL import cancelled."); ++ SetStatus($"{sourceName}: SCL open cancelled."); + } + catch (Exception ex) + { + AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}"); +- SetStatus($"{sourceName}: SCL import failed. Diagnostics is marked with !."); ++ SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !."); + MarkDiagnosticAlert(); + MessageBox.Show( + this, +- $"ArIED could not read this SCL file.\n\n{ex.Message}", ++ $"ArIED could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}", + "Open SCL", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + +- private static string BuildSclEndpointSummary(SclIedEndpoint endpoint) +- { +- var parts = new List(); +- if (!string.IsNullOrWhiteSpace(endpoint.AccessPointName)) +- parts.Add($"AP {endpoint.AccessPointName}"); +- if (!string.IsNullOrWhiteSpace(endpoint.SubNetworkName)) +- parts.Add(endpoint.SubNetworkName); +- return parts.Count == 0 ? "SCL endpoint" : string.Join(" • ", parts); +- } +- ++ private void ApplySclWorkspaceToDevice( ++ Iec61850MonitorDevice device, ++ SclWorkspaceDocument document, ++ SclIedWorkspace workspace, ++ IReadOnlyList signals) ++ { ++ var previousSelection = device.Signals ++ .Where(signal => signal.IsSelected) ++ .Select(signal => NormalizeReference(signal.ObjectReference)) ++ .ToHashSet(StringComparer.OrdinalIgnoreCase); ++ ++ DetachSignalHandlers(device.Signals); ++ device.Signals.Clear(); ++ device.RecountSelectedSignals(); ++ ++ var endpoint = workspace.PreferredEndpoint; ++ device.Name = workspace.IedName; ++ device.IdentitySource = $"SCL design • {document.SourceName}"; ++ device.LogicalDeviceSummary = BuildSclWorkspaceSummary(workspace); ++ if (endpoint?.HasUsableAddress == true) ++ { ++ device.IpAddress = endpoint.IpAddress; ++ device.Port = endpoint.Port; ++ } ++ else if (string.IsNullOrWhiteSpace(device.IpAddress) || device.IpAddress == "192.168.1.10") ++ { ++ device.IpAddress = string.Empty; ++ device.Port = 102; ++ } ++ ++ device.AllowDynamicDataSetWrites = false; ++ device.SclWorkspace = workspace; ++ device.SclComparison = null; ++ device.SclSourcePath = document.SourcePath; ++ device.SclSourceSha256 = document.SourceSha256; ++ device.SclIedName = workspace.IedName; ++ device.SclAccessPointName = workspace.AccessPointName; ++ device.HasDiscoveryCache = signals.Count > 0; ++ device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready"; ++ device.Detail = workspace.RequiresEndpointBinding ++ ? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file." ++ : "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification."; ++ device.AcquisitionMode = "SCL offline design model"; ++ ++ foreach (var signal in signals) ++ { ++ signal.IsSelected = previousSelection.Contains(NormalizeReference(signal.ObjectReference)); ++ signal.PropertyChanged += Signal_PropertyChanged; ++ _signalOwners[signal] = device; ++ } ++ device.Signals.AddRange(signals); ++ device.RecountSelectedSignals(); ++ device.RefreshComputed(); ++ } ++ ++ private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace) ++ { ++ var coverage = workspace.DesignModel.Coverage; ++ var ap = string.IsNullOrWhiteSpace(workspace.AccessPointName) ? "AP unassigned" : $"AP {workspace.AccessPointName}"; ++ return $"{ap} • {coverage.LogicalDeviceCount} LD • {coverage.LogicalNodeCount} LN • {coverage.DataObjectCount} DO • {coverage.DataAttributeCount} DA"; ++ } ++ ++ private void LogSclFindings(string sourceName, IReadOnlyList findings) ++ { ++ foreach (var finding in findings.Take(40)) ++ { ++ var level = finding.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) || ++ finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ++ ? "ERROR" ++ : finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) ? "WARN" : "INFO"; ++ AddLog(level, "SCL", $"{sourceName} • {finding.Code}: {finding.Message}"); ++ } ++ if (findings.Count > 40) ++ AddLog("WARN", "SCL", $"{findings.Count - 40} additional finding(s) were omitted from the live log."); ++ if (findings.Any(finding => finding.Severity is "High" or "Error")) ++ MarkDiagnosticAlert(); ++ } ++ ++ private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device) ++ { ++ if (!device.RequiresEndpointBinding) ++ return true; ++ ++ var initialIp = string.IsNullOrWhiteSpace(NewDeviceIp) ? "192.168.1.10" : NewDeviceIp; ++ var wizard = new IpConnectWizardWindow(initialIp, device.Port <= 0 ? 102 : device.Port) { Owner = this }; ++ if (wizard.ShowDialog() != true) ++ { ++ SetStatus($"{device.Name}: endpoint binding cancelled; the SCL model remains available offline."); ++ return false; ++ } ++ ++ device.IpAddress = wizard.RelayIpAddress; ++ device.Port = wizard.MmsPort; ++ device.Status = "SCL model ready"; ++ device.Detail = "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison."; ++ device.RefreshComputed(); ++ NewDeviceIp = device.IpAddress; ++ NewDevicePort = device.Port.ToString(CultureInfo.InvariantCulture); ++ return true; ++ } + + private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e) + { From 5f10973d7552d2c0140b55148d9334fbc3958b24 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:07:05 +0700 Subject: [PATCH 03/21] chore: stage SCL integration patch 3 --- .integration/mainwindow-3.patch | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 .integration/mainwindow-3.patch diff --git a/.integration/mainwindow-3.patch b/.integration/mainwindow-3.patch new file mode 100644 index 00000000..bd972519 --- /dev/null +++ b/.integration/mainwindow-3.patch @@ -0,0 +1,144 @@ +--- a/MainWindow.xaml.cs ++++ b/MainWindow.xaml.cs +@@ -326,6 +391,14 @@ + { + if (device.IsMonitoring) + return true; ++ if (device.RequiresEndpointBinding) ++ { ++ device.Status = "SCL model ready — endpoint required"; ++ device.Detail = "Connect All skipped this offline SCL workspace because no MMS endpoint is bound."; ++ device.RefreshComputed(); ++ AddLog("WARN", device.Name, "Connect All skipped the SCL workspace because its MMS endpoint is unassigned."); ++ return false; ++ } + + var connected = device.IsConnected; + if (!connected) +@@ -448,6 +521,7 @@ + bool selectDevice = true) + { + if (device.IsBusy) return false; ++ if (!EnsureSclEndpointBinding(device)) return false; + + RememberCurrentSelectionForReconnect(device); + RemoveDevicePoints(device.DeviceId); +@@ -482,6 +556,7 @@ + } + device.Signals.AddRange(signals); + device.HasDiscoveryCache = signals.Count > 0; ++ ApplySclLiveComparison(device, signals); + + try + { +@@ -576,6 +651,7 @@ + bool selectDevice = true) + { + if (device.IsBusy) return false; ++ if (!EnsureSclEndpointBinding(device)) return false; + if (!device.HasDiscoveryCache || device.Signals.Count == 0) + return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice); + +@@ -601,7 +677,17 @@ + device.RecountSelectedSignals(); + await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900)); + RaiseWorkspaceCounts(); +- SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); ++ if (device.HasSclDesignModel) ++ { ++ device.Status = "Connected — SCL design model"; ++ device.Detail = "MMS association is live and the SCL workspace remains the active model. Re-scan performs a complete design-versus-live comparison."; ++ device.AcquisitionMode = "SCL design model • live association"; ++ SetStatus($"{device.Name}: fast connected from the SCL design model; full discovery skipped. Use Re-scan to compare the complete live model."); ++ } ++ else ++ { ++ SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); ++ } + return true; + } + catch (OperationCanceledException) +@@ -642,6 +728,47 @@ + device.IsBusy = false; + device.RefreshComputed(); + } ++ } ++ ++ private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList liveSignals) ++ { ++ if (device.SclWorkspace == null) ++ return; ++ ++ var expectedModel = SclLiveSignalModelProjection.Build( ++ device.SclWorkspace.IedName, ++ device.SclWorkspace.AccessPointName, ++ SclWorkspaceSignalMapper.BuildSignals(device.SclWorkspace)); ++ var observedModel = SclLiveSignalModelProjection.Build( ++ device.Name, ++ device.SclWorkspace.AccessPointName, ++ liveSignals); ++ var comparison = SclLiveModelComparer.Compare(expectedModel, observedModel); ++ device.SclComparison = comparison; ++ foreach (var finding in comparison.Findings.Take(30)) ++ { ++ var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ? "ERROR" : "INFO"; ++ AddLog(level, "SCL Compare", $"{finding.Kind} • {finding.Message}"); ++ } ++ if (comparison.Findings.Count > 30) ++ AddLog("WARN", "SCL Compare", $"{comparison.Findings.Count - 30} additional comparison finding(s) were omitted from the live log."); ++ ++ if (comparison.IsCompatible) ++ { ++ device.IdentitySource = $"SCL + live verified • {Path.GetFileName(device.SclSourcePath)}"; ++ device.AcquisitionMode = "SCL design • live model verified"; ++ device.Detail = $"SCL and live MMS structures are compatible: {comparison.MatchedAttributeCount}/{comparison.ExpectedAttributeCount} expected attributes matched."; ++ AddLog("INFO", device.Name, device.Detail); ++ } ++ else ++ { ++ device.IdentitySource = $"SCL drift detected • {Path.GetFileName(device.SclSourcePath)}"; ++ device.AcquisitionMode = "Live discovery • SCL configuration drift"; ++ device.Detail = $"Live discovery found {comparison.BlockingFindingCount} blocking SCL mismatch(es). Live data is shown; review Diagnostics before testing control or reporting."; ++ MarkDiagnosticAlert(); ++ AddLog("ERROR", device.Name, device.Detail); ++ } ++ device.RefreshComputed(); + } + + private async Task OpenSignalSelectionWizardAsync( +@@ -1102,11 +1229,15 @@ + return; + + SelectedDevice = device; ++ if (!EnsureSclEndpointBinding(device)) ++ return; + RememberCurrentSelectionForReconnect(device); + if (device.IsConnected) + await StopDeviceConnectionAsync(device); + +- SetStatus($"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); ++ SetStatus(device.HasSclDesignModel ++ ? $"{device.Name}: discovering the complete live model and comparing it with the SCL design model." ++ : $"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); + await ConnectAndConfigureDeviceAsync(device, openWizard: false); + } + +@@ -1395,6 +1526,10 @@ + Port = device.Port, + AllowDynamicDataSetWrites = device.AllowDynamicDataSetWrites, + DiscoverySucceeded = device.HasDiscoveryCache && device.Signals.Count > 0, ++ SclSourcePath = device.SclSourcePath, ++ SclSourceSha256 = device.SclSourceSha256, ++ SclIedName = device.SclIedName, ++ SclAccessPointName = device.SclAccessPointName, + SelectedReferences = device.Signals + .Where(signal => signal.IsSelected) + .Select(signal => NormalizeReference(signal.ObjectReference)) +@@ -1459,6 +1594,7 @@ + + foreach (var profile in project.Devices ?? new List()) + { ++ var restoredSclWorkspace = await TryRestoreSclWorkspaceAsync(profile); + var cachedSignals = (profile.CachedSignals ?? new List()) + .Where(item => !string.IsNullOrWhiteSpace(item.ObjectReference)) + .Select(item => item.ToSignal()) From a3ed9deacbebe8379a8716f03c72d7715f671a3b Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:07:37 +0700 Subject: [PATCH 04/21] chore: stage SCL integration patch 4 --- .integration/mainwindow-4.patch | 102 ++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .integration/mainwindow-4.patch diff --git a/.integration/mainwindow-4.patch b/.integration/mainwindow-4.patch new file mode 100644 index 00000000..a9233200 --- /dev/null +++ b/.integration/mainwindow-4.patch @@ -0,0 +1,102 @@ +--- a/MainWindow.xaml.cs ++++ b/MainWindow.xaml.cs +@@ -1466,11 +1602,14 @@ + .GroupBy(item => NormalizeReference(item.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); ++ if (restoredSclWorkspace != null) ++ cachedSignals = SclWorkspaceSignalMapper.BuildSignals(restoredSclWorkspace).ToList(); + var selectedReferences = (profile.SelectedReferences ?? new List()) + .Select(NormalizeReference) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + +- var hasSavedModel = profile.DiscoverySucceeded && cachedSignals.Count > 0; ++ var hasSclProvenance = restoredSclWorkspace != null || !string.IsNullOrWhiteSpace(profile.SclSourceSha256); ++ var hasSavedModel = (profile.DiscoverySucceeded || hasSclProvenance) && cachedSignals.Count > 0; + var device = new Iec61850MonitorDevice + { + DeviceId = string.IsNullOrWhiteSpace(profile.DeviceId) ? Guid.NewGuid().ToString("N") : profile.DeviceId, +@@ -1479,13 +1618,24 @@ + LogicalDeviceSummary = profile.LogicalDeviceSummary, + IpAddress = profile.IpAddress, + Port = profile.Port <= 0 ? 102 : profile.Port, +- AllowDynamicDataSetWrites = profile.AllowDynamicDataSetWrites, ++ AllowDynamicDataSetWrites = hasSclProvenance ? false : profile.AllowDynamicDataSetWrites, ++ SclWorkspace = restoredSclWorkspace, ++ SclSourcePath = profile.SclSourcePath, ++ SclSourceSha256 = profile.SclSourceSha256, ++ SclIedName = profile.SclIedName, ++ SclAccessPointName = profile.SclAccessPointName, + HasDiscoveryCache = hasSavedModel, +- Status = hasSavedModel ? "Saved model ready" : "Discovery required", +- Detail = hasSavedModel +- ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." +- : "This IED has no successful saved discovery. Press Play to scan the live model.", +- AcquisitionMode = hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" ++ Status = hasSclProvenance ++ ? string.IsNullOrWhiteSpace(profile.IpAddress) ? "SCL model ready — bind endpoint" : "SCL model ready" ++ : hasSavedModel ? "Saved model ready" : "Discovery required", ++ Detail = hasSclProvenance ++ ? "SCL design model restored from project provenance. Play fast-connects; Re-scan compares the full live model." ++ : hasSavedModel ++ ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." ++ : "This IED has no successful saved discovery. Press Play to scan the live model.", ++ AcquisitionMode = hasSclProvenance ++ ? "SCL project model • offline" ++ : hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" + }; + Devices.Add(device); + +@@ -1509,12 +1659,51 @@ + SelectedDevice = Devices.FirstOrDefault(); + RaiseWorkspaceCounts(); + var cachedCount = Devices.Count(device => device.HasDiscoveryCache); +- SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} saved discovery model(s) ready for fast Play connect without a full scan."); ++ var sclCount = Devices.Count(device => device.HasSclDesignModel); ++ SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} cached model(s), {sclCount} SCL design model(s) ready for offline browsing and fast Play connect."); + } + catch (Exception ex) + { + AddLog("ERROR", "Project", ex.Message); + SetStatus("Project load failed. Diagnostics is marked with !."); ++ } ++ } ++ ++ private async Task TryRestoreSclWorkspaceAsync(Iec61850TesterDeviceProfile profile) ++ { ++ if (string.IsNullOrWhiteSpace(profile.SclSourcePath) || string.IsNullOrWhiteSpace(profile.SclSourceSha256)) ++ return null; ++ if (!File.Exists(profile.SclSourcePath)) ++ { ++ AddLog("WARN", "SCL", $"Saved SCL source is unavailable: {profile.SclSourcePath}. The cached signal model remains usable."); ++ return null; ++ } ++ ++ try ++ { ++ var document = await _sclWorkspaceService.OpenAsync( ++ profile.SclSourcePath, ++ new SclWorkspaceOpenOptions ++ { ++ IedName = profile.SclIedName, ++ AccessPointName = profile.SclAccessPointName ++ }, ++ _applicationCancellation.Token); ++ if (!document.SourceSha256.Equals(profile.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) ++ { ++ AddLog("ERROR", "SCL", $"Saved SCL source changed on disk: {profile.SclSourcePath}. Cached signals were retained and the changed file was not trusted automatically."); ++ MarkDiagnosticAlert(); ++ return null; ++ } ++ ++ return document.Ieds.FirstOrDefault(item => ++ (string.IsNullOrWhiteSpace(profile.SclIedName) || item.IedName.Equals(profile.SclIedName, StringComparison.OrdinalIgnoreCase)) && ++ (string.IsNullOrWhiteSpace(profile.SclAccessPointName) || item.AccessPointName.Equals(profile.SclAccessPointName, StringComparison.OrdinalIgnoreCase))); ++ } ++ catch (Exception ex) when (ex is not OperationCanceledException) ++ { ++ AddLog("WARN", "SCL", $"Could not restore {profile.SclSourcePath}: {ex.Message}. Cached signals were retained."); ++ return null; + } + } + From 2a1f43338ae498d989653d3175f8bd379a3cc72d Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:07:59 +0700 Subject: [PATCH 05/21] chore: stage SCL model patch 1 --- .integration/monitormodels-1.patch | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .integration/monitormodels-1.patch diff --git a/.integration/monitormodels-1.patch b/.integration/monitormodels-1.patch new file mode 100644 index 00000000..eb0fb001 --- /dev/null +++ b/.integration/monitormodels-1.patch @@ -0,0 +1,84 @@ +--- a/Models/MonitorModels.cs ++++ b/Models/MonitorModels.cs +@@ -1,3 +1,5 @@ ++using AR.Iec61850.Scl.Workspace; ++ + namespace ArIED61850Tester.Models; + + public sealed class Iec61850MonitorDevice : ObservableObject +@@ -28,12 +30,75 @@ + private bool _hasDiscoveryCache; + private string _busyTitle = "Discovering IEC 61850 IED"; + private int _unreadEventCount; ++ private SclIedWorkspace? _sclWorkspace; ++ private SclLiveModelComparisonResult? _sclComparison; ++ private string _sclSourcePath = string.Empty; ++ private string _sclSourceSha256 = string.Empty; ++ private string _sclIedName = string.Empty; ++ private string _sclAccessPointName = string.Empty; + + public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); + public BulkObservableCollection Signals { get; } = new(); + public BulkObservableCollection Points { get; } = new(); + public BulkObservableCollection CommandSignals { get; } = new(); + public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); ++ ++ public SclIedWorkspace? SclWorkspace ++ { ++ get => _sclWorkspace; ++ set ++ { ++ if (ReferenceEquals(_sclWorkspace, value)) return; ++ _sclWorkspace = value; ++ RefreshComputed(); ++ } ++ } ++ ++ public SclLiveModelComparisonResult? SclComparison ++ { ++ get => _sclComparison; ++ set ++ { ++ if (ReferenceEquals(_sclComparison, value)) return; ++ _sclComparison = value; ++ RefreshComputed(); ++ } ++ } ++ ++ public string SclSourcePath ++ { ++ get => _sclSourcePath; ++ set => Set(ref _sclSourcePath, value?.Trim() ?? string.Empty); ++ } ++ ++ public string SclSourceSha256 ++ { ++ get => _sclSourceSha256; ++ set => Set(ref _sclSourceSha256, value?.Trim() ?? string.Empty); ++ } ++ ++ public string SclIedName ++ { ++ get => _sclIedName; ++ set => Set(ref _sclIedName, value?.Trim() ?? string.Empty); ++ } ++ ++ public string SclAccessPointName ++ { ++ get => _sclAccessPointName; ++ set => Set(ref _sclAccessPointName, value?.Trim() ?? string.Empty); ++ } ++ ++ public bool HasSclDesignModel => SclWorkspace != null || !string.IsNullOrWhiteSpace(SclSourceSha256); ++ public bool RequiresEndpointBinding => HasSclDesignModel && string.IsNullOrWhiteSpace(IpAddress); ++ public bool HasSclConfigurationDrift => SclComparison?.RequiresFullDiscovery == true; ++ public string SclVerificationText => !HasSclDesignModel ++ ? string.Empty ++ : SclComparison == null ++ ? IsConnected ? "SCL associated • full model unverified" : "SCL offline model" ++ : SclComparison.IsCompatible ++ ? "SCL verified against live model" ++ : $"SCL drift • {SclComparison.BlockingFindingCount} blocking finding(s)"; + + // Smart Auto reporting: existing static RCB/DataSet first, temporary association- + // scoped dynamic DataSet/URCB second, MMS polling only when reporting cannot be armed. From 29eca4d203d1300ec884c3742c0059cddbc9ddca Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:08:33 +0700 Subject: [PATCH 06/21] chore: stage SCL model patch 2 --- .integration/monitormodels-2.patch | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 .integration/monitormodels-2.patch diff --git a/.integration/monitormodels-2.patch b/.integration/monitormodels-2.patch new file mode 100644 index 00000000..05607fad --- /dev/null +++ b/.integration/monitormodels-2.patch @@ -0,0 +1,103 @@ +--- a/Models/MonitorModels.cs ++++ b/Models/MonitorModels.cs +@@ -320,28 +385,42 @@ + public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); + public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring); + public bool CanRescan => !IsBusy && !IsMonitoring; +- public string CacheStateText => HasDiscoveryCache +- ? $"Saved model ready • {SignalCount:N0} signals" +- : "No saved model • discovery required"; +- public string ReadyWorkspaceTitle => HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; +- public string ReadyWorkspaceMessage => HasDiscoveryCache && SelectedLiveSignalCount > 0 +- ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." ++ public string CacheStateText => HasSclDesignModel ++ ? $"SCL design model • {SignalCount:N0} signals" + : HasDiscoveryCache +- ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." +- : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; ++ ? $"Saved live model • {SignalCount:N0} signals" ++ : "No cached model • discovery required"; ++ public string ReadyWorkspaceTitle => HasSclDesignModel ++ ? RequiresEndpointBinding ? "SCL model ready — endpoint required" : "SCL design model is ready" ++ : HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; ++ public string ReadyWorkspaceMessage => HasSclDesignModel ++ ? RequiresEndpointBinding ++ ? "The LD/LN/DO/DA model is available offline. Press Play to bind an MMS endpoint before connecting." ++ : SelectedLiveSignalCount > 0 ++ ? $"{SelectedLiveSignalCount:N0} SCL signal(s) are selected. Play performs a fast MMS association; Re-scan compares the complete live model." ++ : "Browse and choose signals offline. Play associates without repeating full discovery; Re-scan performs design-versus-live verification." ++ : HasDiscoveryCache && SelectedLiveSignalCount > 0 ++ ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." ++ : HasDiscoveryCache ++ ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." ++ : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; + public string ConnectionActionLabel => IsBusy ? "Working…" : IsConnected ? "Disconnect" : "Connect"; + public string MonitorActionLabel => IsBusy ? "Working…" : IsMonitoring ? "Stop Monitor" : "Start Monitor"; + public string ActivityText => IsBusy ? "Working…" : IsMonitoring ? "Monitoring" : Status; + public string SummaryText => $"{SignalCount} scanned • {SelectedSignalCount} selected • {PointCount} live"; + public string IdentityText => string.IsNullOrWhiteSpace(LogicalDeviceSummary) + ? EndpointText +- : $"{EndpointText} • LD {LogicalDeviceSummary}"; ++ : HasSclDesignModel ++ ? $"{EndpointText} • {LogicalDeviceSummary}" ++ : $"{EndpointText} • LD {LogicalDeviceSummary}"; + public string ConnectionGlyph => IsBusy ? "…" : IsConnected ? "⏻" : "↗"; + public string ConnectionToolTip => IsBusy + ? "IED connection operation is running" + : IsConnected + ? $"Disconnect {Name}" +- : $"Connect and discover {EndpointText}"; ++ : HasSclDesignModel ++ ? $"Connect {Name} using the SCL design model" ++ : $"Connect and discover {EndpointText}"; + public string MonitorGlyph => IsBusy ? "…" : IsMonitoring ? "■" : "▶"; + public string MonitorToolTip => IsBusy + ? "IED session operation is running" +@@ -354,9 +433,13 @@ + ? $"Stop monitoring {Name} before changing its signal selection" + : $"Open signal selection wizard for {Name}"; + public string PlayToolTip => !IsConnected +- ? HasDiscoveryCache +- ? $"Fast-connect {Name} from the saved model and start its selected live values" +- : $"Connect and discover {EndpointText}" ++ ? RequiresEndpointBinding ++ ? $"Bind an MMS endpoint for {Name}, then fast-connect from the SCL design model" ++ : HasSclDesignModel ++ ? $"Fast-connect {Name} from the SCL design model; use Re-scan for full live comparison" ++ : HasDiscoveryCache ++ ? $"Fast-connect {Name} from the saved model and start its selected live values" ++ : $"Connect and discover {EndpointText}" + : IsMonitoring + ? $"{Name} is already monitoring" + : SelectedLiveSignalCount == 0 +@@ -436,6 +519,10 @@ + Raise(nameof(CanStopAction)); + Raise(nameof(CanRescan)); + Raise(nameof(CacheStateText)); ++ Raise(nameof(HasSclDesignModel)); ++ Raise(nameof(RequiresEndpointBinding)); ++ Raise(nameof(HasSclConfigurationDrift)); ++ Raise(nameof(SclVerificationText)); + Raise(nameof(ReadyWorkspaceTitle)); + Raise(nameof(ReadyWorkspaceMessage)); + Raise(nameof(ConnectionActionLabel)); +@@ -681,7 +768,7 @@ + + public sealed class Iec61850TesterProject + { +- public int SchemaVersion { get; set; } = 2; ++ public int SchemaVersion { get; set; } = 3; + public string ProjectName { get; set; } = "ArIED 61850 Session"; + public int DefaultPollingIntervalMs { get; set; } = 1000; + public List Devices { get; set; } = new(); +@@ -697,6 +784,10 @@ + public int Port { get; set; } = 102; + public bool AllowDynamicDataSetWrites { get; set; } = true; + public bool DiscoverySucceeded { get; set; } ++ public string SclSourcePath { get; set; } = string.Empty; ++ public string SclSourceSha256 { get; set; } = string.Empty; ++ public string SclIedName { get; set; } = string.Empty; ++ public string SclAccessPointName { get; set; } = string.Empty; + public List SelectedReferences { get; set; } = new(); + public List CachedSignals { get; set; } = new(); + } From 6d460585795067adc5411f900245834c8f86d922 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:09:26 +0700 Subject: [PATCH 07/21] chore: stage SCL signal mapper --- .integration/mapper.patch | 264 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 .integration/mapper.patch diff --git a/.integration/mapper.patch b/.integration/mapper.patch new file mode 100644 index 00000000..6ea4565a --- /dev/null +++ b/.integration/mapper.patch @@ -0,0 +1,264 @@ +--- /dev/null ++++ b/Services/SclWorkspaceSignalMapper.cs +@@ -0,0 +1,261 @@ ++using AR.Iec61850.Discovery; ++using AR.Iec61850.Scl.Workspace; ++using ArIED61850Tester.Models; ++ ++namespace ArIED61850Tester.Services; ++ ++/// ++/// Presentation adapter from the engine-owned SCL workspace model to ArIED signal rows. ++/// This class deliberately contains no XML parsing or IEC 61850 type-template traversal. ++/// ++public static class SclWorkspaceSignalMapper ++{ ++ private static readonly HashSet RuntimeLeafNames = new(StringComparer.OrdinalIgnoreCase) ++ { ++ "stVal", "general", "posVal", "actVal", "setVal", "f", "i" ++ }; ++ ++ public static IReadOnlyList BuildSignals(SclIedWorkspace workspace) ++ { ++ ArgumentNullException.ThrowIfNull(workspace); ++ ++ var dataSetBindings = BuildDataSetBindings(workspace.DesignModel); ++ var reportBindings = BuildReportBindings(workspace.DesignModel); ++ var signals = new List(); ++ ++ foreach (var logicalDevice in workspace.DesignModel.LogicalDevices) ++ { ++ foreach (var logicalNode in logicalDevice.LogicalNodes) ++ { ++ foreach (var dataObject in logicalNode.DataObjects) ++ { ++ AddRuntimeSignals(signals, logicalNode, dataObject, dataSetBindings, reportBindings); ++ AddControlSignal(signals, logicalNode, dataObject, dataSetBindings, reportBindings); ++ } ++ } ++ } ++ ++ return signals ++ .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal) ++ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) ++ .Select(group => group.First()) ++ .OrderBy(signal => signal.SortPriority) ++ .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) ++ .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) ++ .ToArray(); ++ } ++ ++ private static void AddRuntimeSignals( ++ ICollection signals, ++ LiveIedLogicalNodeModel logicalNode, ++ LiveIedDataObjectModel dataObject, ++ IReadOnlyDictionary dataSetBindings, ++ IReadOnlyDictionary reportBindings) ++ { ++ var quality = FindCompanion(dataObject, "q"); ++ var timestamp = FindCompanion(dataObject, "t"); ++ ++ foreach (var attribute in dataObject.Attributes) ++ { ++ var fc = (attribute.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); ++ if (fc is not ("ST" or "MX") || IsCompanion(attribute.AttributePath)) ++ continue; ++ if (!IsRuntimeLeaf(attribute.AttributePath)) ++ continue; ++ ++ var reference = attribute.ObjectReference; ++ var normalized = NormalizeReference(reference); ++ dataSetBindings.TryGetValue(normalized, out var dataSetReference); ++ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); ++ var category = ResolveCategory(logicalNode.LnClass, dataObject.Name, dataObject.InferredCdc, fc); ++ ++ signals.Add(new SignalDefinition ++ { ++ Name = BuildDisplayName(logicalNode.Name, dataObject.Name, attribute.AttributePath), ++ ObjectReference = reference, ++ DisplayReference = reference, ++ FunctionalConstraint = fc, ++ DataType = ResolveDataType(attribute), ++ Category = category, ++ Confidence = attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High ++ ? "High" ++ : "Medium", ++ DataSetReference = dataSetReference ?? string.Empty, ++ ReportControlReference = reportReference ?? string.Empty, ++ ReportCoverageReason = string.IsNullOrWhiteSpace(reportReference) ++ ? "SCL design model contains no static ReportControl coverage; polling remains the safe fallback." ++ : $"Static SCL report candidate {reportReference}; live RCB attributes are verified before enable.", ++ QualityReference = quality?.ObjectReference ?? string.Empty, ++ TimestampReference = timestamp?.ObjectReference ?? string.Empty, ++ Source = "SCL design model", ++ IsReportCapable = !string.IsNullOrWhiteSpace(reportReference), ++ ReportCoverage = string.IsNullOrWhiteSpace(reportReference) ++ ? "MMS polling fallback" ++ : "Static SCL report candidate", ++ IsSelected = false, ++ Value = "-", ++ Quality = "Unknown", ++ DeviceTimestamp = "-", ++ ProbeStatus = "Projected from SCL; live read verification pending" ++ }); ++ } ++ } ++ ++ private static void AddControlSignal( ++ ICollection signals, ++ LiveIedLogicalNodeModel logicalNode, ++ LiveIedDataObjectModel dataObject, ++ IReadOnlyDictionary dataSetBindings, ++ IReadOnlyDictionary reportBindings) ++ { ++ var controlAttributes = dataObject.Attributes ++ .Where(attribute => string.Equals(attribute.FunctionalConstraint, "CO", StringComparison.OrdinalIgnoreCase) || ++ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)) ++ .ToArray(); ++ if (controlAttributes.Length == 0) ++ return; ++ ++ var controlReference = dataObject.Reference; ++ var ctlModel = dataObject.Attributes.FirstOrDefault(attribute => ++ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)); ++ var status = dataObject.Attributes.FirstOrDefault(attribute => ++ Leaf(attribute.AttributePath).Equals("stVal", StringComparison.OrdinalIgnoreCase)); ++ var normalizedStatus = NormalizeReference(status?.ObjectReference); ++ dataSetBindings.TryGetValue(normalizedStatus, out var dataSetReference); ++ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); ++ ++ signals.Add(new SignalDefinition ++ { ++ Name = $"{logicalNode.Name} {dataObject.Name}", ++ ObjectReference = controlReference, ++ DisplayReference = controlReference, ++ FunctionalConstraint = "CO", ++ DataType = string.IsNullOrWhiteSpace(dataObject.InferredCdc) ++ ? "IEC 61850 control" ++ : $"{dataObject.InferredCdc} control", ++ Category = "Control", ++ Confidence = "High", ++ DataSetReference = dataSetReference ?? string.Empty, ++ ReportControlReference = reportReference ?? string.Empty, ++ ReportCoverageReason = "Control execution is disabled until the live ctlModel and exact MMS Oper/SBOw/Cancel structures are inspected.", ++ Source = "SCL design model", ++ IsControlSignal = true, ++ ControlCdc = dataObject.InferredCdc, ++ ControlModelReference = ctlModel?.ObjectReference ?? $"{controlReference}.ctlModel", ++ ControlStatusReference = status?.ObjectReference ?? string.Empty, ++ ControlModelText = "SCL design • live verification required", ++ ControlValueType = ResolveControlValueType(dataObject.InferredCdc), ++ IsSelected = false, ++ Value = "-", ++ Quality = "Unknown", ++ DeviceTimestamp = "-", ++ ProbeStatus = "SCL control candidate; live verification required" ++ }); ++ } ++ ++ private static Dictionary BuildDataSetBindings(LiveIedModelDiscoveryDocument model) ++ { ++ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); ++ foreach (var dataSet in model.DataSets) ++ { ++ foreach (var member in dataSet.Members) ++ { ++ var key = NormalizeReference(member.Reference); ++ if (!string.IsNullOrWhiteSpace(key)) ++ bindings.TryAdd(key, dataSet.Reference); ++ } ++ } ++ return bindings; ++ } ++ ++ private static Dictionary BuildReportBindings(LiveIedModelDiscoveryDocument model) ++ { ++ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); ++ foreach (var report in model.ReportControls) ++ { ++ var key = NormalizeReference(report.DataSetReference); ++ if (!string.IsNullOrWhiteSpace(key)) ++ bindings.TryAdd(key, report.Reference); ++ } ++ return bindings; ++ } ++ ++ private static LiveIedDataAttributeModel? FindCompanion(LiveIedDataObjectModel dataObject, string leaf) ++ => dataObject.Attributes.FirstOrDefault(attribute => Leaf(attribute.AttributePath).Equals(leaf, StringComparison.OrdinalIgnoreCase)); ++ ++ private static bool IsCompanion(string path) ++ { ++ var leaf = Leaf(path); ++ return leaf.Equals("q", StringComparison.OrdinalIgnoreCase) || ++ leaf.Equals("t", StringComparison.OrdinalIgnoreCase) || ++ leaf.Equals("ctlModel", StringComparison.OrdinalIgnoreCase); ++ } ++ ++ private static bool IsRuntimeLeaf(string path) ++ { ++ var leaf = Leaf(path); ++ if (RuntimeLeafNames.Contains(leaf)) ++ return true; ++ ++ var normalized = path.Replace('$', '.'); ++ return normalized.EndsWith("mag.f", StringComparison.OrdinalIgnoreCase) || ++ normalized.EndsWith("ang.f", StringComparison.OrdinalIgnoreCase) || ++ normalized.EndsWith("instMag.i", StringComparison.OrdinalIgnoreCase); ++ } ++ ++ private static string ResolveDataType(LiveIedDataAttributeModel attribute) ++ { ++ if (!string.IsNullOrWhiteSpace(attribute.SclBType)) ++ return attribute.SclBType; ++ if (!string.IsNullOrWhiteSpace(attribute.MmsType)) ++ return attribute.MmsType; ++ return "IEC 61850 value"; ++ } ++ ++ private static string ResolveCategory(string lnClass, string doName, string cdc, string fc) ++ { ++ if (fc.Equals("MX", StringComparison.OrdinalIgnoreCase)) ++ return "Measurement"; ++ if (doName.Equals("Pos", StringComparison.OrdinalIgnoreCase) || cdc.Equals("DPC", StringComparison.OrdinalIgnoreCase)) ++ return "Position"; ++ if (lnClass.StartsWith('P') || doName.Equals("Op", StringComparison.OrdinalIgnoreCase) || doName.Equals("Str", StringComparison.OrdinalIgnoreCase)) ++ return "Protection"; ++ return "Status"; ++ } ++ ++ private static string ResolveControlValueType(string cdc) ++ => (cdc ?? string.Empty).Trim().ToUpperInvariant() switch ++ { ++ "DPC" => "Dbpos", ++ "SPC" => "Boolean", ++ "INC" or "ISC" or "BSC" => "Int32", ++ "APC" or "BAC" => "Float32", ++ _ => string.Empty ++ }; ++ ++ private static string BuildDisplayName(string logicalNode, string dataObject, string attributePath) ++ => $"{logicalNode} {dataObject} {attributePath}"; ++ ++ private static string Leaf(string? path) ++ { ++ var text = (path ?? string.Empty).Replace('$', '.').Trim('.'); ++ var index = text.LastIndexOf('.'); ++ return index >= 0 ? text[(index + 1)..] : text; ++ } ++ ++ private static string NormalizeReference(string? reference) ++ { ++ var text = (reference ?? string.Empty).Trim(); ++ var fcMarker = text.LastIndexOf(" [", StringComparison.Ordinal); ++ if (fcMarker >= 0) ++ text = text[..fcMarker]; ++ text = text.Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim(); ++ ++ // SCL FCDA display references commonly use IED/LD/LN while MMS domains use IEDLD/LN. ++ var parts = text.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); ++ if (parts.Length >= 3) ++ text = string.Concat(parts[0], parts[1], "/", string.Join("/", parts.Skip(2))); ++ ++ return text.ToUpperInvariant(); ++ } ++} From a995294f9b1482764ee0dc5311a6eca6f79d47f9 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:10:53 +0700 Subject: [PATCH 08/21] chore: stage live model projection --- .integration/projection.patch | 300 ++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 .integration/projection.patch diff --git a/.integration/projection.patch b/.integration/projection.patch new file mode 100644 index 00000000..81b650e6 --- /dev/null +++ b/.integration/projection.patch @@ -0,0 +1,300 @@ +--- /dev/null ++++ b/Services/SclLiveSignalModelProjection.cs +@@ -0,0 +1,276 @@ ++using AR.Iec61850.Discovery; ++using ArIED61850Tester.Models; ++ ++namespace ArIED61850Tester.Services; ++ ++/// ++/// Builds a bounded engine discovery document from ArIED signal rows so the ++/// ARIEC61850 SCL comparer remains the only owner of design-versus-live rules. ++/// The projection is intentionally limited to model elements exposed by the ++/// application discovery workflow: DA references, FC/type evidence, DataSets, ++/// and ReportControl bindings. ++/// ++public static class SclLiveSignalModelProjection ++{ ++ public static LiveIedModelDiscoveryDocument Build( ++ string iedName, ++ string accessPointName, ++ IEnumerable signals) ++ { ++ var rows = (signals ?? Array.Empty()) ++ .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) ++ .ToArray(); ++ ++ var logicalDevices = rows ++ .Select(ToDescriptor) ++ .Where(descriptor => descriptor != null) ++ .Cast() ++ .GroupBy(descriptor => descriptor.Domain, StringComparer.OrdinalIgnoreCase) ++ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) ++ .Select(domain => new LiveIedLogicalDeviceModel ++ { ++ MmsDomain = domain.Key, ++ Inst = LogicalDeviceInst(domain.Key, iedName), ++ LogicalNodes = domain ++ .GroupBy(descriptor => descriptor.LogicalNode, StringComparer.OrdinalIgnoreCase) ++ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) ++ .Select(logicalNode => BuildLogicalNode(logicalNode.Key, logicalNode)) ++ .ToArray() ++ }) ++ .ToArray(); ++ ++ var dataSets = BuildDataSets(rows); ++ var reports = BuildReportControls(rows, dataSets); ++ var coverage = BuildCoverage(logicalDevices, dataSets, reports); ++ ++ return new LiveIedModelDiscoveryDocument ++ { ++ Source = "ArIEDSignalProjection", ++ IedName = iedName, ++ AccessPointName = accessPointName, ++ LogicalDevices = logicalDevices, ++ DataSets = dataSets, ++ ReportControls = reports, ++ Coverage = coverage, ++ Summary = $"ArIED runtime projection: LD={coverage.LogicalDeviceCount}, LN={coverage.LogicalNodeCount}, DO={coverage.DataObjectCount}, DA={coverage.DataAttributeCount}, RCB={coverage.ReportControlCount}, DataSet={coverage.DataSetCount}." ++ }; ++ } ++ ++ private static LiveIedLogicalNodeModel BuildLogicalNode( ++ string logicalNodeName, ++ IEnumerable descriptors) ++ { ++ var descriptorArray = descriptors.ToArray(); ++ var parts = SignalDefinition.DetectLogicalNodeClass(logicalNodeName); ++ var dataObjects = descriptorArray ++ .GroupBy(descriptor => descriptor.DataObject, StringComparer.OrdinalIgnoreCase) ++ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) ++ .Select(group => BuildDataObject(group.Key, group)) ++ .ToArray(); ++ ++ return new LiveIedLogicalNodeModel ++ { ++ Name = logicalNodeName, ++ LnClass = parts, ++ ProposedLnTypeId = $"ARIED_{SafeId(parts)}_{SafeId(logicalNodeName)}", ++ FunctionalConstraintCounts = dataObjects ++ .SelectMany(dataObject => dataObject.Attributes) ++ .Where(attribute => !string.IsNullOrWhiteSpace(attribute.FunctionalConstraint)) ++ .GroupBy(attribute => attribute.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) ++ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase), ++ DataObjects = dataObjects ++ }; ++ } ++ ++ private static LiveIedDataObjectModel BuildDataObject( ++ string dataObjectName, ++ IEnumerable descriptors) ++ { ++ var descriptorArray = descriptors.ToArray(); ++ var primary = descriptorArray.First(); ++ var attributes = descriptorArray ++ .Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.AttributePath)) ++ .GroupBy(descriptor => NormalizeReference(descriptor.Reference), StringComparer.OrdinalIgnoreCase) ++ .Select(group => group.First()) ++ .OrderBy(descriptor => descriptor.AttributePath, StringComparer.OrdinalIgnoreCase) ++ .Select(descriptor => new LiveIedDataAttributeModel ++ { ++ ObjectReference = descriptor.Reference, ++ AttributePath = descriptor.AttributePath, ++ FunctionalConstraint = descriptor.FunctionalConstraint, ++ MmsReference = descriptor.Reference, ++ MmsItemName = descriptor.Reference.Contains('/') ++ ? descriptor.Reference[(descriptor.Reference.IndexOf('/') + 1)..] ++ : descriptor.Reference, ++ Source = "ArIED signal model", ++ SclBType = descriptor.DataType, ++ MmsType = descriptor.DataType, ++ MmsTypeSignature = descriptor.DataType, ++ TypeDiscoveryStatus = "Projected", ++ TypeDiscoveryMessage = "Projected from the ArIED discovery signal row.", ++ TypeSource = "ArIED discovery", ++ TypeConfidence = LiveIedDiscoveryConfidenceLevel.High, ++ FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(descriptor.FunctionalConstraint) ++ ? LiveIedDiscoveryConfidenceLevel.Unknown ++ : LiveIedDiscoveryConfidenceLevel.Exact ++ }) ++ .ToArray(); ++ ++ return new LiveIedDataObjectModel ++ { ++ Reference = primary.ObjectReference, ++ Name = dataObjectName, ++ ProposedDoTypeId = $"ARIED_DO_{SafeId(primary.Cdc)}_{SafeId(dataObjectName)}", ++ InferredCdc = primary.Cdc, ++ CdcConfidence = string.IsNullOrWhiteSpace(primary.Cdc) ? 0.5 : 0.9, ++ ConfidenceLevel = string.IsNullOrWhiteSpace(primary.Cdc) ++ ? LiveIedDiscoveryConfidenceLevel.Medium ++ : LiveIedDiscoveryConfidenceLevel.High, ++ Evidence = new[] { "Projected from ArIED live discovery signal metadata." }, ++ Attributes = attributes ++ }; ++ } ++ ++ private static IReadOnlyList BuildDataSets(IReadOnlyList signals) ++ => signals ++ .Where(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) ++ .GroupBy(signal => signal.DataSetReference.Trim(), StringComparer.OrdinalIgnoreCase) ++ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) ++ .Select(group => ++ { ++ var reference = group.Key; ++ var domain = Domain(reference); ++ var tail = ReferenceTail(reference); ++ var separator = tail.LastIndexOf('.'); ++ var logicalNode = separator > 0 ? tail[..separator] : string.Empty; ++ var name = separator > 0 ? tail[(separator + 1)..] : tail; ++ var members = group ++ .Where(signal => !signal.IsControlSignal) ++ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) ++ .Select(values => values.First()) ++ .Select((signal, index) => new LiveIedDataSetMemberModel ++ { ++ Index = index + 1, ++ Reference = signal.ObjectReference, ++ FunctionalConstraint = signal.FunctionalConstraint, ++ MmsReference = signal.ObjectReference, ++ Confidence = LiveIedDiscoveryConfidenceLevel.High ++ }) ++ .ToArray(); ++ ++ return new LiveIedDataSetModel ++ { ++ Reference = reference, ++ Domain = domain, ++ LogicalNode = logicalNode, ++ Name = name, ++ MemberCount = members.Length, ++ Members = members ++ }; ++ }) ++ .ToArray(); ++ ++ private static IReadOnlyList BuildReportControls( ++ IReadOnlyList signals, ++ IReadOnlyList dataSets) ++ { ++ var dataSetIndex = dataSets.ToDictionary( ++ dataSet => NormalizeReference(dataSet.Reference), ++ dataSet => dataSet, ++ StringComparer.OrdinalIgnoreCase); ++ ++ return signals ++ .Where(signal => !string.IsNullOrWhiteSpace(signal.ReportControlReference)) ++ .GroupBy(signal => signal.ReportControlReference.Trim(), StringComparer.OrdinalIgnoreCase) ++ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) ++ .Select(group => ++ { ++ var first = group.First(); ++ var reference = group.Key; ++ var tail = ReferenceTail(reference); ++ var name = tail.Contains('.') ? tail[(tail.LastIndexOf('.') + 1)..] : tail; ++ dataSetIndex.TryGetValue(NormalizeReference(first.DataSetReference), out var dataSet); ++ return new LiveIedReportControlModel ++ { ++ Reference = reference, ++ Domain = Domain(reference), ++ LogicalNode = tail.Contains('.') ? tail[..tail.IndexOf('.')] : "LLN0", ++ Name = name, ++ Buffered = NormalizeReference(reference).Contains(".BR.", StringComparison.OrdinalIgnoreCase), ++ DataSetReference = dataSet?.Reference ?? first.DataSetReference, ++ Status = "Projected from ArIED discovery" ++ }; ++ }) ++ .ToArray(); ++ } ++ ++ private static LiveIedModelDiscoveryCoverage BuildCoverage( ++ IReadOnlyList logicalDevices, ++ IReadOnlyList dataSets, ++ IReadOnlyList reports) ++ { ++ var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray(); ++ var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray(); ++ var attributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray(); ++ return new LiveIedModelDiscoveryCoverage ++ { ++ LogicalDeviceCount = logicalDevices.Count, ++ LogicalNodeCount = logicalNodes.Length, ++ DataObjectCount = dataObjects.Length, ++ DataAttributeCount = attributes.Length, ++ ExactFunctionalConstraintCount = attributes.Count(attribute => attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact), ++ ExactMmsTypeCount = attributes.Count(attribute => !string.IsNullOrWhiteSpace(attribute.MmsType)), ++ HighConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High), ++ MediumConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel == LiveIedDiscoveryConfidenceLevel.Medium), ++ DataSetCount = dataSets.Count, ++ ReportControlCount = reports.Count, ++ BufferedReportControlCount = reports.Count(report => report.Buffered), ++ UnbufferedReportControlCount = reports.Count(report => !report.Buffered) ++ }; ++ } ++ ++ private static SignalDescriptor? ToDescriptor(SignalDefinition signal) ++ { ++ var reference = signal.ObjectReference.Replace('$', '.').Trim(); ++ var slash = reference.IndexOf('/'); ++ if (slash <= 0 || slash >= reference.Length - 1) ++ return null; ++ ++ var domain = reference[..slash]; ++ var member = reference[(slash + 1)..]; ++ var segments = member.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); ++ if (segments.Length < 2) ++ return null; ++ ++ var logicalNode = segments[0]; ++ var dataObject = segments[1]; ++ var attributePath = segments.Length > 2 ? string.Join('.', segments.Skip(2)) : string.Empty; ++ if (signal.IsControlSignal && string.IsNullOrWhiteSpace(attributePath)) ++ attributePath = "Oper.ctlVal"; ++ ++ return new SignalDescriptor( ++ domain, ++ logicalNode, ++ dataObject, ++ attributePath, ++ reference, ++ $"{domain}/{logicalNode}.{dataObject}", ++ signal.FunctionalConstraint, ++ signal.DataType, ++ signal.ControlCdc); ++ } ++ ++ private static string Domain(string reference) ++ { ++ var slash = reference.IndexOf('/'); ++ return slash > 0 ? reference[..slash] : string.Empty; ++ } ++ ++ private static string ReferenceTail(string reference) ++ { ++ var text = reference.Replace('$', '.'); ++ var slash = text.IndexOf('/'); ++ return slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text; ++ } ++ ++ private static string LogicalDeviceInst(string domain, string iedName) ++ => !string.IsNullOrWhiteSpace(iedName) && domain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) ++ ? domain[iedName.Length..] ++ : domain; ++ ++ private static string NormalizeReference(string? reference) ++ => (reference ?? string.Empty).Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim().ToUpperInvariant(); ++ ++ private static string SafeId(string? value) ++ => string.Concat((value ?? string.Empty).Where(character => char.IsLetterOrDigit(character) || character == '_')); ++ ++ private sealed record SignalDescriptor( ++ string Domain, ++ string LogicalNode, ++ string DataObject, ++ string AttributePath, ++ string Reference, ++ string ObjectReference, ++ string FunctionalConstraint, ++ string DataType, ++ string Cdc); ++} From df66b58d6bac0dc3ca69fdbd4c7c5128389567c4 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:11:17 +0700 Subject: [PATCH 09/21] chore: stage integration build workflow --- .integration/build.yml | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .integration/build.yml diff --git a/.integration/build.yml b/.integration/build.yml new file mode 100644 index 00000000..a57edd07 --- /dev/null +++ b/.integration/build.yml @@ -0,0 +1,87 @@ +name: Build ArIED 61850 + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +env: + # Temporary integration pin. Switch this to main after ARIEC61850 PR #25 is merged. + ARIEC61850_REF: agent/scl-workspace-api + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout ArIED application + shell: powershell + run: | + $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } + git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester + + - name: Verify ArIED clean-room source + shell: powershell + run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 + + - name: Upload source snapshot + uses: actions/upload-artifact@v4 + with: + name: ArIED61850-source-snapshot + path: | + ArIED61850Tester/App.xaml + ArIED61850Tester/MainWindow.xaml + ArIED61850Tester/MainWindow.xaml.cs + ArIED61850Tester/MainWindow.CommandPanelUx.cs + ArIED61850Tester/MainWindow.ControlDiagnostics.cs + ArIED61850Tester/GridUxBehavior.cs + ArIED61850Tester/Models + ArIED61850Tester/Services + + - name: Checkout ARIEC61850 engine + shell: powershell + run: git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850 + + - name: Verify required ARIEC61850 APIs + shell: powershell + run: | + $control = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs" + $models = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlModels.cs" + $workspace = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceService.cs" + $workspaceModels = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceModels.cs" + if (!(Test-Path $control) -or !(Test-Path $models)) { + throw "ARIEC61850 Smart Control source was not found. ArIED requires the native Control namespace." + } + if (!(Select-String -Path $models -Pattern "interface IIec61850ControlService" -Quiet)) { + throw "ARIEC61850 does not expose IIec61850ControlService." + } + if (!(Select-String -Path $models -Pattern "CommandTerminationReceived" -Quiet)) { + throw "ARIEC61850 Smart Control result contract is too old for ArIED." + } + if (!(Test-Path $workspace) -or !(Test-Path $workspaceModels)) { + throw "ARIEC61850 SCL Workspace API was not found. ArIED Open SCL must use the engine-owned workspace service." + } + if (!(Select-String -Path $workspace -Pattern "CompareLive" -Quiet)) { + throw "ARIEC61850 SCL Workspace API does not expose design-versus-live comparison." + } + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore .\ArIED61850Tester\ArIED61850Tester.csproj + + - name: Build + run: dotnet build .\ArIED61850Tester\ArIED61850Tester.csproj -c Release --no-restore + + - name: Publish portable x64 + shell: powershell + run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.6 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" + + - name: Upload portable package + uses: actions/upload-artifact@v4 + with: + name: ArIED61850-win-x64 + path: ArIED61850Tester\dist\*.zip From f115411a316589d7903d2e802e0a6cd634fe1cfc Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:11:29 +0700 Subject: [PATCH 10/21] chore: stage SCL integration documentation --- .integration/SCL_WORKSPACE_INTEGRATION.md | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .integration/SCL_WORKSPACE_INTEGRATION.md diff --git a/.integration/SCL_WORKSPACE_INTEGRATION.md b/.integration/SCL_WORKSPACE_INTEGRATION.md new file mode 100644 index 00000000..68030262 --- /dev/null +++ b/.integration/SCL_WORKSPACE_INTEGRATION.md @@ -0,0 +1,52 @@ +# ArIED SCL Workspace Integration + +ArIED delegates SCL parsing, endpoint resolution, type-template projection, and expected-versus-live comparison to the reusable ARIEC61850 engine. + +## Open SCL + +`MainWindow.OpenScl_Click` calls `SclWorkspaceService.OpenAsync`. Opening an ICD, CID, IID, SCD, SSD, or XML SCL file is offline and sends no MMS traffic. + +For each IED and AccessPoint returned by the engine, ArIED creates an independent workspace containing: + +- IED and AccessPoint identity; +- direct MMS endpoint when present; +- LD/LN/DO/DA model projected from `DataTypeTemplates`; +- static DataSet and ReportControl bindings; +- GOOSE and Sampled Values engineering metadata; +- source file path and SHA-256 provenance; +- typed SCL findings. + +An ICD without `Communication` remains browseable. Pressing Play opens the existing endpoint wizard so an MMS address can be bound locally. + +## Connection behavior + +Play uses the existing cached-model connection path when an SCL design model is available: + +```text +SCL workspace +→ offline signal projection +→ TCP/ACSE/MMS association +→ selected-point verification and acquisition +``` + +It does not repeat full GetNameList discovery. Dynamic DataSet writes default to disabled for SCL workspaces. + +Re-scan intentionally performs full live discovery. ArIED converts the resulting runtime signal inventory into a bounded `LiveIedModelDiscoveryDocument` and sends both expected and observed projections to the engine-owned `SclLiveModelComparer`. Compatible models are marked verified. Missing visible attributes, FC/type differences, DataSet/RCB differences, or identity mismatch are surfaced as configuration drift in Diagnostics. + +Control objects remain unavailable for operation until ArIED performs the existing live `ctlModel`, Oper/SBOw/Cancel, and exact MMS type inspection. + +## Project persistence + +Project schema version 3 stores: + +- SCL source path; +- SHA-256 source hash; +- IED name; +- AccessPoint name; +- cached signal projection and user selections. + +When reopening a project, ArIED reloads the engine workspace only when the source hash still matches. A missing or changed source file never silently replaces the cached model. + +## Dependency gate + +Until ARIEC61850 PR #25 is merged, the ArIED CI workflow pins `ARIEC61850_REF` to `agent/scl-workspace-api`. After merge, change the value to `main` in `.github/workflows/build.yml`. From 199df7bc7a586cfde4dc18225ffb7e387862eb9d Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:12:15 +0700 Subject: [PATCH 11/21] chore: apply staged SCL integration --- .github/workflows/apply-scl-integration.yml | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/apply-scl-integration.yml diff --git a/.github/workflows/apply-scl-integration.yml b/.github/workflows/apply-scl-integration.yml new file mode 100644 index 00000000..c5fdd25b --- /dev/null +++ b/.github/workflows/apply-scl-integration.yml @@ -0,0 +1,54 @@ +name: Apply staged SCL integration + +on: + push: + branches: [ agent/use-engine-scl-workspace ] + +permissions: + contents: write + +jobs: + apply: + if: ${{ !contains(github.event.head_commit.message, '[applied]') }} + runs-on: ubuntu-latest + steps: + - name: Checkout integration branch + uses: actions/checkout@v4 + with: + ref: agent/use-engine-scl-workspace + fetch-depth: 0 + + - name: Apply staged source changes + shell: bash + run: | + set -euo pipefail + for patch in \ + .integration/mainwindow-1.patch \ + .integration/mainwindow-2.patch \ + .integration/mainwindow-3.patch \ + .integration/mainwindow-4.patch \ + .integration/monitormodels-1.patch \ + .integration/monitormodels-2.patch \ + .integration/mapper.patch \ + .integration/projection.patch + do + git apply --check --ignore-space-change --ignore-whitespace "$patch" + git apply --ignore-space-change --ignore-whitespace "$patch" + done + + cp .integration/build.yml .github/workflows/build.yml + mkdir -p docs + cp .integration/SCL_WORKSPACE_INTEGRATION.md docs/SCL_WORKSPACE_INTEGRATION.md + rm Services/SclImportService.cs + rm -rf .integration + rm .github/workflows/apply-scl-integration.yml + + - name: Commit final integration source + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Integrate engine-owned SCL workspace [applied]" + git push origin HEAD:agent/use-engine-scl-workspace From cc01ab86d6545201d975bd0433bf5575ddccb723 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:14:15 +0700 Subject: [PATCH 12/21] ci: apply SCL integration from pull request --- .github/workflows/apply-scl-integration.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/apply-scl-integration.yml b/.github/workflows/apply-scl-integration.yml index c5fdd25b..6fd0fafe 100644 --- a/.github/workflows/apply-scl-integration.yml +++ b/.github/workflows/apply-scl-integration.yml @@ -1,15 +1,16 @@ name: Apply staged SCL integration on: - push: - branches: [ agent/use-engine-scl-workspace ] + pull_request: + branches: [ main ] + workflow_dispatch: permissions: contents: write jobs: apply: - if: ${{ !contains(github.event.head_commit.message, '[applied]') }} + if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'agent/use-engine-scl-workspace') }} runs-on: ubuntu-latest steps: - name: Checkout integration branch From 0db954e2ad0804676dd6d49144a0d4d879be264c Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:15:46 +0700 Subject: [PATCH 13/21] ci: separate source apply from workflow updates --- .github/workflows/apply-scl-integration.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/apply-scl-integration.yml b/.github/workflows/apply-scl-integration.yml index 6fd0fafe..927b7b4c 100644 --- a/.github/workflows/apply-scl-integration.yml +++ b/.github/workflows/apply-scl-integration.yml @@ -37,12 +37,10 @@ jobs: git apply --ignore-space-change --ignore-whitespace "$patch" done - cp .integration/build.yml .github/workflows/build.yml mkdir -p docs cp .integration/SCL_WORKSPACE_INTEGRATION.md docs/SCL_WORKSPACE_INTEGRATION.md rm Services/SclImportService.cs rm -rf .integration - rm .github/workflows/apply-scl-integration.yml - name: Commit final integration source shell: bash From 47861c71d5fea4327b16dd3065488f340ce1f316 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:15:57 +0000 Subject: [PATCH 14/21] Integrate engine-owned SCL workspace [applied] --- .integration/build.yml | 87 ----- .integration/mainwindow-1.patch | 126 ------- .integration/mainwindow-2.patch | 148 -------- .integration/mainwindow-3.patch | 144 -------- .integration/mainwindow-4.patch | 102 ------ .integration/mapper.patch | 264 -------------- .integration/monitormodels-1.patch | 84 ----- .integration/monitormodels-2.patch | 103 ------ .integration/projection.patch | 300 ---------------- MainWindow.xaml.cs | 335 ++++++++++++++---- Models/MonitorModels.cs | 119 ++++++- Services/SclImportService.cs | 208 ----------- Services/SclLiveSignalModelProjection.cs | 276 +++++++++++++++ Services/SclWorkspaceSignalMapper.cs | 261 ++++++++++++++ .../SCL_WORKSPACE_INTEGRATION.md | 0 15 files changed, 904 insertions(+), 1653 deletions(-) delete mode 100644 .integration/build.yml delete mode 100644 .integration/mainwindow-1.patch delete mode 100644 .integration/mainwindow-2.patch delete mode 100644 .integration/mainwindow-3.patch delete mode 100644 .integration/mainwindow-4.patch delete mode 100644 .integration/mapper.patch delete mode 100644 .integration/monitormodels-1.patch delete mode 100644 .integration/monitormodels-2.patch delete mode 100644 .integration/projection.patch delete mode 100644 Services/SclImportService.cs create mode 100644 Services/SclLiveSignalModelProjection.cs create mode 100644 Services/SclWorkspaceSignalMapper.cs rename {.integration => docs}/SCL_WORKSPACE_INTEGRATION.md (100%) diff --git a/.integration/build.yml b/.integration/build.yml deleted file mode 100644 index a57edd07..00000000 --- a/.integration/build.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: Build ArIED 61850 - -on: - push: - branches: [ main ] - pull_request: - workflow_dispatch: - -env: - # Temporary integration pin. Switch this to main after ARIEC61850 PR #25 is merged. - ARIEC61850_REF: agent/scl-workspace-api - -jobs: - build-windows: - runs-on: windows-latest - steps: - - name: Checkout ArIED application - shell: powershell - run: | - $ref = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME } - git clone --quiet --depth 1 --branch $ref "https://github.com/$env:GITHUB_REPOSITORY.git" ArIED61850Tester - - - name: Verify ArIED clean-room source - shell: powershell - run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 - - - name: Upload source snapshot - uses: actions/upload-artifact@v4 - with: - name: ArIED61850-source-snapshot - path: | - ArIED61850Tester/App.xaml - ArIED61850Tester/MainWindow.xaml - ArIED61850Tester/MainWindow.xaml.cs - ArIED61850Tester/MainWindow.CommandPanelUx.cs - ArIED61850Tester/MainWindow.ControlDiagnostics.cs - ArIED61850Tester/GridUxBehavior.cs - ArIED61850Tester/Models - ArIED61850Tester/Services - - - name: Checkout ARIEC61850 engine - shell: powershell - run: git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850 - - - name: Verify required ARIEC61850 APIs - shell: powershell - run: | - $control = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs" - $models = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlModels.cs" - $workspace = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceService.cs" - $workspaceModels = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceModels.cs" - if (!(Test-Path $control) -or !(Test-Path $models)) { - throw "ARIEC61850 Smart Control source was not found. ArIED requires the native Control namespace." - } - if (!(Select-String -Path $models -Pattern "interface IIec61850ControlService" -Quiet)) { - throw "ARIEC61850 does not expose IIec61850ControlService." - } - if (!(Select-String -Path $models -Pattern "CommandTerminationReceived" -Quiet)) { - throw "ARIEC61850 Smart Control result contract is too old for ArIED." - } - if (!(Test-Path $workspace) -or !(Test-Path $workspaceModels)) { - throw "ARIEC61850 SCL Workspace API was not found. ArIED Open SCL must use the engine-owned workspace service." - } - if (!(Select-String -Path $workspace -Pattern "CompareLive" -Quiet)) { - throw "ARIEC61850 SCL Workspace API does not expose design-versus-live comparison." - } - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore - run: dotnet restore .\ArIED61850Tester\ArIED61850Tester.csproj - - - name: Build - run: dotnet build .\ArIED61850Tester\ArIED61850Tester.csproj -c Release --no-restore - - - name: Publish portable x64 - shell: powershell - run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.6 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" - - - name: Upload portable package - uses: actions/upload-artifact@v4 - with: - name: ArIED61850-win-x64 - path: ArIED61850Tester\dist\*.zip diff --git a/.integration/mainwindow-1.patch b/.integration/mainwindow-1.patch deleted file mode 100644 index 67c6eeae..00000000 --- a/.integration/mainwindow-1.patch +++ /dev/null @@ -1,126 +0,0 @@ ---- a/MainWindow.xaml.cs -+++ b/MainWindow.xaml.cs -@@ -11,6 +11,7 @@ - using System.Windows.Threading; - using System.Windows.Media; - using System.Windows.Media.Animation; -+using AR.Iec61850.Scl.Workspace; - using ArIED61850Tester.Models; - using ArIED61850Tester.Services; - using Microsoft.Win32; -@@ -20,6 +21,7 @@ - public partial class MainWindow : Window, INotifyPropertyChanged - { - private readonly Iec61850MonitorRuntime _runtime = new(); -+ private readonly SclWorkspaceService _sclWorkspaceService = new(); - private readonly CancellationTokenSource _applicationCancellation = new(); - private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _signalOwners = new(); -@@ -160,28 +162,18 @@ - return; - - var sourceName = Path.GetFileName(dialog.FileName); -- SetStatus($"Reading IED endpoints from {sourceName}…"); -+ SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…"); - try - { -- var result = await SclImportService.LoadAsync(dialog.FileName, _applicationCancellation.Token); -- foreach (var warning in result.Warnings.Take(25)) -- AddLog("WARN", "SCL", warning); -- if (result.Warnings.Count > 25) -- AddLog("WARN", "SCL", $"{result.Warnings.Count - 25} additional SCL warning(s) were omitted from the live log."); -- -- if (result.Endpoints.Count == 0) -- { -- var reason = result.ConnectedAccessPointCount == 0 -- ? "No ConnectedAP communication entries were found." -- : "ConnectedAP entries were found, but none contained a valid IP address."; -- SetStatus($"{sourceName}: no usable IEC 61850 MMS endpoints. {reason}"); -- AddLog("WARN", "SCL", $"{sourceName}: {reason}"); -- MessageBox.Show( -- this, -- $"No usable IEC 61850 MMS endpoint was found in {sourceName}.\n\n{reason}\n\nThe file may contain only an IED template without a Communication section.", -- "Open SCL", -- MessageBoxButton.OK, -- MessageBoxImage.Information); -+ var document = await _sclWorkspaceService.OpenAsync( -+ dialog.FileName, -+ cancellationToken: _applicationCancellation.Token); -+ LogSclFindings(sourceName, document.Findings); -+ -+ if (document.Ieds.Count == 0) -+ { -+ SetStatus($"{sourceName}: no IED model was found."); -+ AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace."); - return; - } - -@@ -190,51 +182,33 @@ - var retained = 0; - Iec61850MonitorDevice? firstImported = null; - -- foreach (var endpoint in result.Endpoints) -+ foreach (var workspace in document.Ieds) - { - var device = Devices.FirstOrDefault(item => -- item.IpAddress.Equals(endpoint.IpAddress, StringComparison.OrdinalIgnoreCase) && -- item.Port == endpoint.Port); -- -+ item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && -+ item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && -+ item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); -+ -+ if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) -+ { -+ retained++; -+ firstImported ??= device; -+ continue; -+ } -+ -+ var signals = SclWorkspaceSignalMapper.BuildSignals(workspace); - if (device == null) - { -- device = new Iec61850MonitorDevice -- { -- Name = endpoint.IedName, -- IdentitySource = $"SCL • {sourceName}", -- LogicalDeviceSummary = BuildSclEndpointSummary(endpoint), -- IpAddress = endpoint.IpAddress, -- Port = endpoint.Port, -- AllowDynamicDataSetWrites = true, -- Status = "SCL endpoint ready", -- Detail = $"Imported from {sourceName}. Press Play to connect and verify the live IEC 61850 model.", -- AcquisitionMode = "SCL • live discovery pending" -- }; -+ device = new Iec61850MonitorDevice(); - Devices.Add(device); - added++; - } -- else if (!device.IsConnected && !device.IsBusy && !device.HasDiscoveryCache) -+ else - { -- if (string.IsNullOrWhiteSpace(device.Name) || -- device.Name.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) -- { -- device.Name = endpoint.IedName; -- } -- device.IdentitySource = $"SCL • {sourceName}"; -- device.LogicalDeviceSummary = BuildSclEndpointSummary(endpoint); -- device.Status = "SCL endpoint ready"; -- device.Detail = $"Endpoint refreshed from {sourceName}. Press Play to connect and verify the live IEC 61850 model."; -- device.AcquisitionMode = "SCL • live discovery pending"; -- device.RefreshComputed(); - refreshed++; - } -- else -- { -- // Preserve active sessions and successful discovery caches. SCL is an -- // endpoint-import path, never authority over a verified live model. -- retained++; -- } -- -+ -+ ApplySclWorkspaceToDevice(device, document, workspace, signals); - firstImported ??= device; - } - diff --git a/.integration/mainwindow-2.patch b/.integration/mainwindow-2.patch deleted file mode 100644 index 6078743b..00000000 --- a/.integration/mainwindow-2.patch +++ /dev/null @@ -1,148 +0,0 @@ ---- a/MainWindow.xaml.cs -+++ b/MainWindow.xaml.cs -@@ -244,39 +218,130 @@ - UpdateNavigationVisuals(0, animate: true); - RaiseWorkspaceCounts(); - -- var warningText = result.Warnings.Count == 0 ? string.Empty : $", {result.Warnings.Count} warning(s)"; -- var status = $"{sourceName}: {result.Endpoints.Count} SCL endpoint(s) read — {added} added, {refreshed} refreshed, {retained} existing retained{warningText}. Use Play or Connect All for live verification."; -+ var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline); -+ var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding); -+ var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained."; - SetStatus(status); - AddLog("INFO", "SCL", status); - } - catch (OperationCanceledException) - { -- SetStatus($"{sourceName}: SCL import cancelled."); -+ SetStatus($"{sourceName}: SCL open cancelled."); - } - catch (Exception ex) - { - AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}"); -- SetStatus($"{sourceName}: SCL import failed. Diagnostics is marked with !."); -+ SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !."); - MarkDiagnosticAlert(); - MessageBox.Show( - this, -- $"ArIED could not read this SCL file.\n\n{ex.Message}", -+ $"ArIED could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}", - "Open SCL", - MessageBoxButton.OK, - MessageBoxImage.Error); - } - } - -- private static string BuildSclEndpointSummary(SclIedEndpoint endpoint) -- { -- var parts = new List(); -- if (!string.IsNullOrWhiteSpace(endpoint.AccessPointName)) -- parts.Add($"AP {endpoint.AccessPointName}"); -- if (!string.IsNullOrWhiteSpace(endpoint.SubNetworkName)) -- parts.Add(endpoint.SubNetworkName); -- return parts.Count == 0 ? "SCL endpoint" : string.Join(" • ", parts); -- } -- -+ private void ApplySclWorkspaceToDevice( -+ Iec61850MonitorDevice device, -+ SclWorkspaceDocument document, -+ SclIedWorkspace workspace, -+ IReadOnlyList signals) -+ { -+ var previousSelection = device.Signals -+ .Where(signal => signal.IsSelected) -+ .Select(signal => NormalizeReference(signal.ObjectReference)) -+ .ToHashSet(StringComparer.OrdinalIgnoreCase); -+ -+ DetachSignalHandlers(device.Signals); -+ device.Signals.Clear(); -+ device.RecountSelectedSignals(); -+ -+ var endpoint = workspace.PreferredEndpoint; -+ device.Name = workspace.IedName; -+ device.IdentitySource = $"SCL design • {document.SourceName}"; -+ device.LogicalDeviceSummary = BuildSclWorkspaceSummary(workspace); -+ if (endpoint?.HasUsableAddress == true) -+ { -+ device.IpAddress = endpoint.IpAddress; -+ device.Port = endpoint.Port; -+ } -+ else if (string.IsNullOrWhiteSpace(device.IpAddress) || device.IpAddress == "192.168.1.10") -+ { -+ device.IpAddress = string.Empty; -+ device.Port = 102; -+ } -+ -+ device.AllowDynamicDataSetWrites = false; -+ device.SclWorkspace = workspace; -+ device.SclComparison = null; -+ device.SclSourcePath = document.SourcePath; -+ device.SclSourceSha256 = document.SourceSha256; -+ device.SclIedName = workspace.IedName; -+ device.SclAccessPointName = workspace.AccessPointName; -+ device.HasDiscoveryCache = signals.Count > 0; -+ device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready"; -+ device.Detail = workspace.RequiresEndpointBinding -+ ? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file." -+ : "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification."; -+ device.AcquisitionMode = "SCL offline design model"; -+ -+ foreach (var signal in signals) -+ { -+ signal.IsSelected = previousSelection.Contains(NormalizeReference(signal.ObjectReference)); -+ signal.PropertyChanged += Signal_PropertyChanged; -+ _signalOwners[signal] = device; -+ } -+ device.Signals.AddRange(signals); -+ device.RecountSelectedSignals(); -+ device.RefreshComputed(); -+ } -+ -+ private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace) -+ { -+ var coverage = workspace.DesignModel.Coverage; -+ var ap = string.IsNullOrWhiteSpace(workspace.AccessPointName) ? "AP unassigned" : $"AP {workspace.AccessPointName}"; -+ return $"{ap} • {coverage.LogicalDeviceCount} LD • {coverage.LogicalNodeCount} LN • {coverage.DataObjectCount} DO • {coverage.DataAttributeCount} DA"; -+ } -+ -+ private void LogSclFindings(string sourceName, IReadOnlyList findings) -+ { -+ foreach (var finding in findings.Take(40)) -+ { -+ var level = finding.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) || -+ finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) -+ ? "ERROR" -+ : finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) ? "WARN" : "INFO"; -+ AddLog(level, "SCL", $"{sourceName} • {finding.Code}: {finding.Message}"); -+ } -+ if (findings.Count > 40) -+ AddLog("WARN", "SCL", $"{findings.Count - 40} additional finding(s) were omitted from the live log."); -+ if (findings.Any(finding => finding.Severity is "High" or "Error")) -+ MarkDiagnosticAlert(); -+ } -+ -+ private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device) -+ { -+ if (!device.RequiresEndpointBinding) -+ return true; -+ -+ var initialIp = string.IsNullOrWhiteSpace(NewDeviceIp) ? "192.168.1.10" : NewDeviceIp; -+ var wizard = new IpConnectWizardWindow(initialIp, device.Port <= 0 ? 102 : device.Port) { Owner = this }; -+ if (wizard.ShowDialog() != true) -+ { -+ SetStatus($"{device.Name}: endpoint binding cancelled; the SCL model remains available offline."); -+ return false; -+ } -+ -+ device.IpAddress = wizard.RelayIpAddress; -+ device.Port = wizard.MmsPort; -+ device.Status = "SCL model ready"; -+ device.Detail = "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison."; -+ device.RefreshComputed(); -+ NewDeviceIp = device.IpAddress; -+ NewDevicePort = device.Port.ToString(CultureInfo.InvariantCulture); -+ return true; -+ } - - private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e) - { diff --git a/.integration/mainwindow-3.patch b/.integration/mainwindow-3.patch deleted file mode 100644 index bd972519..00000000 --- a/.integration/mainwindow-3.patch +++ /dev/null @@ -1,144 +0,0 @@ ---- a/MainWindow.xaml.cs -+++ b/MainWindow.xaml.cs -@@ -326,6 +391,14 @@ - { - if (device.IsMonitoring) - return true; -+ if (device.RequiresEndpointBinding) -+ { -+ device.Status = "SCL model ready — endpoint required"; -+ device.Detail = "Connect All skipped this offline SCL workspace because no MMS endpoint is bound."; -+ device.RefreshComputed(); -+ AddLog("WARN", device.Name, "Connect All skipped the SCL workspace because its MMS endpoint is unassigned."); -+ return false; -+ } - - var connected = device.IsConnected; - if (!connected) -@@ -448,6 +521,7 @@ - bool selectDevice = true) - { - if (device.IsBusy) return false; -+ if (!EnsureSclEndpointBinding(device)) return false; - - RememberCurrentSelectionForReconnect(device); - RemoveDevicePoints(device.DeviceId); -@@ -482,6 +556,7 @@ - } - device.Signals.AddRange(signals); - device.HasDiscoveryCache = signals.Count > 0; -+ ApplySclLiveComparison(device, signals); - - try - { -@@ -576,6 +651,7 @@ - bool selectDevice = true) - { - if (device.IsBusy) return false; -+ if (!EnsureSclEndpointBinding(device)) return false; - if (!device.HasDiscoveryCache || device.Signals.Count == 0) - return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice); - -@@ -601,7 +677,17 @@ - device.RecountSelectedSignals(); - await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900)); - RaiseWorkspaceCounts(); -- SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); -+ if (device.HasSclDesignModel) -+ { -+ device.Status = "Connected — SCL design model"; -+ device.Detail = "MMS association is live and the SCL workspace remains the active model. Re-scan performs a complete design-versus-live comparison."; -+ device.AcquisitionMode = "SCL design model • live association"; -+ SetStatus($"{device.Name}: fast connected from the SCL design model; full discovery skipped. Use Re-scan to compare the complete live model."); -+ } -+ else -+ { -+ SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); -+ } - return true; - } - catch (OperationCanceledException) -@@ -642,6 +728,47 @@ - device.IsBusy = false; - device.RefreshComputed(); - } -+ } -+ -+ private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList liveSignals) -+ { -+ if (device.SclWorkspace == null) -+ return; -+ -+ var expectedModel = SclLiveSignalModelProjection.Build( -+ device.SclWorkspace.IedName, -+ device.SclWorkspace.AccessPointName, -+ SclWorkspaceSignalMapper.BuildSignals(device.SclWorkspace)); -+ var observedModel = SclLiveSignalModelProjection.Build( -+ device.Name, -+ device.SclWorkspace.AccessPointName, -+ liveSignals); -+ var comparison = SclLiveModelComparer.Compare(expectedModel, observedModel); -+ device.SclComparison = comparison; -+ foreach (var finding in comparison.Findings.Take(30)) -+ { -+ var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ? "ERROR" : "INFO"; -+ AddLog(level, "SCL Compare", $"{finding.Kind} • {finding.Message}"); -+ } -+ if (comparison.Findings.Count > 30) -+ AddLog("WARN", "SCL Compare", $"{comparison.Findings.Count - 30} additional comparison finding(s) were omitted from the live log."); -+ -+ if (comparison.IsCompatible) -+ { -+ device.IdentitySource = $"SCL + live verified • {Path.GetFileName(device.SclSourcePath)}"; -+ device.AcquisitionMode = "SCL design • live model verified"; -+ device.Detail = $"SCL and live MMS structures are compatible: {comparison.MatchedAttributeCount}/{comparison.ExpectedAttributeCount} expected attributes matched."; -+ AddLog("INFO", device.Name, device.Detail); -+ } -+ else -+ { -+ device.IdentitySource = $"SCL drift detected • {Path.GetFileName(device.SclSourcePath)}"; -+ device.AcquisitionMode = "Live discovery • SCL configuration drift"; -+ device.Detail = $"Live discovery found {comparison.BlockingFindingCount} blocking SCL mismatch(es). Live data is shown; review Diagnostics before testing control or reporting."; -+ MarkDiagnosticAlert(); -+ AddLog("ERROR", device.Name, device.Detail); -+ } -+ device.RefreshComputed(); - } - - private async Task OpenSignalSelectionWizardAsync( -@@ -1102,11 +1229,15 @@ - return; - - SelectedDevice = device; -+ if (!EnsureSclEndpointBinding(device)) -+ return; - RememberCurrentSelectionForReconnect(device); - if (device.IsConnected) - await StopDeviceConnectionAsync(device); - -- SetStatus($"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); -+ SetStatus(device.HasSclDesignModel -+ ? $"{device.Name}: discovering the complete live model and comparing it with the SCL design model." -+ : $"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); - await ConnectAndConfigureDeviceAsync(device, openWizard: false); - } - -@@ -1395,6 +1526,10 @@ - Port = device.Port, - AllowDynamicDataSetWrites = device.AllowDynamicDataSetWrites, - DiscoverySucceeded = device.HasDiscoveryCache && device.Signals.Count > 0, -+ SclSourcePath = device.SclSourcePath, -+ SclSourceSha256 = device.SclSourceSha256, -+ SclIedName = device.SclIedName, -+ SclAccessPointName = device.SclAccessPointName, - SelectedReferences = device.Signals - .Where(signal => signal.IsSelected) - .Select(signal => NormalizeReference(signal.ObjectReference)) -@@ -1459,6 +1594,7 @@ - - foreach (var profile in project.Devices ?? new List()) - { -+ var restoredSclWorkspace = await TryRestoreSclWorkspaceAsync(profile); - var cachedSignals = (profile.CachedSignals ?? new List()) - .Where(item => !string.IsNullOrWhiteSpace(item.ObjectReference)) - .Select(item => item.ToSignal()) diff --git a/.integration/mainwindow-4.patch b/.integration/mainwindow-4.patch deleted file mode 100644 index a9233200..00000000 --- a/.integration/mainwindow-4.patch +++ /dev/null @@ -1,102 +0,0 @@ ---- a/MainWindow.xaml.cs -+++ b/MainWindow.xaml.cs -@@ -1466,11 +1602,14 @@ - .GroupBy(item => NormalizeReference(item.ObjectReference), StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .ToList(); -+ if (restoredSclWorkspace != null) -+ cachedSignals = SclWorkspaceSignalMapper.BuildSignals(restoredSclWorkspace).ToList(); - var selectedReferences = (profile.SelectedReferences ?? new List()) - .Select(NormalizeReference) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - -- var hasSavedModel = profile.DiscoverySucceeded && cachedSignals.Count > 0; -+ var hasSclProvenance = restoredSclWorkspace != null || !string.IsNullOrWhiteSpace(profile.SclSourceSha256); -+ var hasSavedModel = (profile.DiscoverySucceeded || hasSclProvenance) && cachedSignals.Count > 0; - var device = new Iec61850MonitorDevice - { - DeviceId = string.IsNullOrWhiteSpace(profile.DeviceId) ? Guid.NewGuid().ToString("N") : profile.DeviceId, -@@ -1479,13 +1618,24 @@ - LogicalDeviceSummary = profile.LogicalDeviceSummary, - IpAddress = profile.IpAddress, - Port = profile.Port <= 0 ? 102 : profile.Port, -- AllowDynamicDataSetWrites = profile.AllowDynamicDataSetWrites, -+ AllowDynamicDataSetWrites = hasSclProvenance ? false : profile.AllowDynamicDataSetWrites, -+ SclWorkspace = restoredSclWorkspace, -+ SclSourcePath = profile.SclSourcePath, -+ SclSourceSha256 = profile.SclSourceSha256, -+ SclIedName = profile.SclIedName, -+ SclAccessPointName = profile.SclAccessPointName, - HasDiscoveryCache = hasSavedModel, -- Status = hasSavedModel ? "Saved model ready" : "Discovery required", -- Detail = hasSavedModel -- ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." -- : "This IED has no successful saved discovery. Press Play to scan the live model.", -- AcquisitionMode = hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" -+ Status = hasSclProvenance -+ ? string.IsNullOrWhiteSpace(profile.IpAddress) ? "SCL model ready — bind endpoint" : "SCL model ready" -+ : hasSavedModel ? "Saved model ready" : "Discovery required", -+ Detail = hasSclProvenance -+ ? "SCL design model restored from project provenance. Play fast-connects; Re-scan compares the full live model." -+ : hasSavedModel -+ ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." -+ : "This IED has no successful saved discovery. Press Play to scan the live model.", -+ AcquisitionMode = hasSclProvenance -+ ? "SCL project model • offline" -+ : hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" - }; - Devices.Add(device); - -@@ -1509,12 +1659,51 @@ - SelectedDevice = Devices.FirstOrDefault(); - RaiseWorkspaceCounts(); - var cachedCount = Devices.Count(device => device.HasDiscoveryCache); -- SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} saved discovery model(s) ready for fast Play connect without a full scan."); -+ var sclCount = Devices.Count(device => device.HasSclDesignModel); -+ SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} cached model(s), {sclCount} SCL design model(s) ready for offline browsing and fast Play connect."); - } - catch (Exception ex) - { - AddLog("ERROR", "Project", ex.Message); - SetStatus("Project load failed. Diagnostics is marked with !."); -+ } -+ } -+ -+ private async Task TryRestoreSclWorkspaceAsync(Iec61850TesterDeviceProfile profile) -+ { -+ if (string.IsNullOrWhiteSpace(profile.SclSourcePath) || string.IsNullOrWhiteSpace(profile.SclSourceSha256)) -+ return null; -+ if (!File.Exists(profile.SclSourcePath)) -+ { -+ AddLog("WARN", "SCL", $"Saved SCL source is unavailable: {profile.SclSourcePath}. The cached signal model remains usable."); -+ return null; -+ } -+ -+ try -+ { -+ var document = await _sclWorkspaceService.OpenAsync( -+ profile.SclSourcePath, -+ new SclWorkspaceOpenOptions -+ { -+ IedName = profile.SclIedName, -+ AccessPointName = profile.SclAccessPointName -+ }, -+ _applicationCancellation.Token); -+ if (!document.SourceSha256.Equals(profile.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) -+ { -+ AddLog("ERROR", "SCL", $"Saved SCL source changed on disk: {profile.SclSourcePath}. Cached signals were retained and the changed file was not trusted automatically."); -+ MarkDiagnosticAlert(); -+ return null; -+ } -+ -+ return document.Ieds.FirstOrDefault(item => -+ (string.IsNullOrWhiteSpace(profile.SclIedName) || item.IedName.Equals(profile.SclIedName, StringComparison.OrdinalIgnoreCase)) && -+ (string.IsNullOrWhiteSpace(profile.SclAccessPointName) || item.AccessPointName.Equals(profile.SclAccessPointName, StringComparison.OrdinalIgnoreCase))); -+ } -+ catch (Exception ex) when (ex is not OperationCanceledException) -+ { -+ AddLog("WARN", "SCL", $"Could not restore {profile.SclSourcePath}: {ex.Message}. Cached signals were retained."); -+ return null; - } - } - diff --git a/.integration/mapper.patch b/.integration/mapper.patch deleted file mode 100644 index 6ea4565a..00000000 --- a/.integration/mapper.patch +++ /dev/null @@ -1,264 +0,0 @@ ---- /dev/null -+++ b/Services/SclWorkspaceSignalMapper.cs -@@ -0,0 +1,261 @@ -+using AR.Iec61850.Discovery; -+using AR.Iec61850.Scl.Workspace; -+using ArIED61850Tester.Models; -+ -+namespace ArIED61850Tester.Services; -+ -+/// -+/// Presentation adapter from the engine-owned SCL workspace model to ArIED signal rows. -+/// This class deliberately contains no XML parsing or IEC 61850 type-template traversal. -+/// -+public static class SclWorkspaceSignalMapper -+{ -+ private static readonly HashSet RuntimeLeafNames = new(StringComparer.OrdinalIgnoreCase) -+ { -+ "stVal", "general", "posVal", "actVal", "setVal", "f", "i" -+ }; -+ -+ public static IReadOnlyList BuildSignals(SclIedWorkspace workspace) -+ { -+ ArgumentNullException.ThrowIfNull(workspace); -+ -+ var dataSetBindings = BuildDataSetBindings(workspace.DesignModel); -+ var reportBindings = BuildReportBindings(workspace.DesignModel); -+ var signals = new List(); -+ -+ foreach (var logicalDevice in workspace.DesignModel.LogicalDevices) -+ { -+ foreach (var logicalNode in logicalDevice.LogicalNodes) -+ { -+ foreach (var dataObject in logicalNode.DataObjects) -+ { -+ AddRuntimeSignals(signals, logicalNode, dataObject, dataSetBindings, reportBindings); -+ AddControlSignal(signals, logicalNode, dataObject, dataSetBindings, reportBindings); -+ } -+ } -+ } -+ -+ return signals -+ .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal) -+ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) -+ .Select(group => group.First()) -+ .OrderBy(signal => signal.SortPriority) -+ .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) -+ .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) -+ .ToArray(); -+ } -+ -+ private static void AddRuntimeSignals( -+ ICollection signals, -+ LiveIedLogicalNodeModel logicalNode, -+ LiveIedDataObjectModel dataObject, -+ IReadOnlyDictionary dataSetBindings, -+ IReadOnlyDictionary reportBindings) -+ { -+ var quality = FindCompanion(dataObject, "q"); -+ var timestamp = FindCompanion(dataObject, "t"); -+ -+ foreach (var attribute in dataObject.Attributes) -+ { -+ var fc = (attribute.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); -+ if (fc is not ("ST" or "MX") || IsCompanion(attribute.AttributePath)) -+ continue; -+ if (!IsRuntimeLeaf(attribute.AttributePath)) -+ continue; -+ -+ var reference = attribute.ObjectReference; -+ var normalized = NormalizeReference(reference); -+ dataSetBindings.TryGetValue(normalized, out var dataSetReference); -+ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); -+ var category = ResolveCategory(logicalNode.LnClass, dataObject.Name, dataObject.InferredCdc, fc); -+ -+ signals.Add(new SignalDefinition -+ { -+ Name = BuildDisplayName(logicalNode.Name, dataObject.Name, attribute.AttributePath), -+ ObjectReference = reference, -+ DisplayReference = reference, -+ FunctionalConstraint = fc, -+ DataType = ResolveDataType(attribute), -+ Category = category, -+ Confidence = attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High -+ ? "High" -+ : "Medium", -+ DataSetReference = dataSetReference ?? string.Empty, -+ ReportControlReference = reportReference ?? string.Empty, -+ ReportCoverageReason = string.IsNullOrWhiteSpace(reportReference) -+ ? "SCL design model contains no static ReportControl coverage; polling remains the safe fallback." -+ : $"Static SCL report candidate {reportReference}; live RCB attributes are verified before enable.", -+ QualityReference = quality?.ObjectReference ?? string.Empty, -+ TimestampReference = timestamp?.ObjectReference ?? string.Empty, -+ Source = "SCL design model", -+ IsReportCapable = !string.IsNullOrWhiteSpace(reportReference), -+ ReportCoverage = string.IsNullOrWhiteSpace(reportReference) -+ ? "MMS polling fallback" -+ : "Static SCL report candidate", -+ IsSelected = false, -+ Value = "-", -+ Quality = "Unknown", -+ DeviceTimestamp = "-", -+ ProbeStatus = "Projected from SCL; live read verification pending" -+ }); -+ } -+ } -+ -+ private static void AddControlSignal( -+ ICollection signals, -+ LiveIedLogicalNodeModel logicalNode, -+ LiveIedDataObjectModel dataObject, -+ IReadOnlyDictionary dataSetBindings, -+ IReadOnlyDictionary reportBindings) -+ { -+ var controlAttributes = dataObject.Attributes -+ .Where(attribute => string.Equals(attribute.FunctionalConstraint, "CO", StringComparison.OrdinalIgnoreCase) || -+ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)) -+ .ToArray(); -+ if (controlAttributes.Length == 0) -+ return; -+ -+ var controlReference = dataObject.Reference; -+ var ctlModel = dataObject.Attributes.FirstOrDefault(attribute => -+ Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)); -+ var status = dataObject.Attributes.FirstOrDefault(attribute => -+ Leaf(attribute.AttributePath).Equals("stVal", StringComparison.OrdinalIgnoreCase)); -+ var normalizedStatus = NormalizeReference(status?.ObjectReference); -+ dataSetBindings.TryGetValue(normalizedStatus, out var dataSetReference); -+ reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); -+ -+ signals.Add(new SignalDefinition -+ { -+ Name = $"{logicalNode.Name} {dataObject.Name}", -+ ObjectReference = controlReference, -+ DisplayReference = controlReference, -+ FunctionalConstraint = "CO", -+ DataType = string.IsNullOrWhiteSpace(dataObject.InferredCdc) -+ ? "IEC 61850 control" -+ : $"{dataObject.InferredCdc} control", -+ Category = "Control", -+ Confidence = "High", -+ DataSetReference = dataSetReference ?? string.Empty, -+ ReportControlReference = reportReference ?? string.Empty, -+ ReportCoverageReason = "Control execution is disabled until the live ctlModel and exact MMS Oper/SBOw/Cancel structures are inspected.", -+ Source = "SCL design model", -+ IsControlSignal = true, -+ ControlCdc = dataObject.InferredCdc, -+ ControlModelReference = ctlModel?.ObjectReference ?? $"{controlReference}.ctlModel", -+ ControlStatusReference = status?.ObjectReference ?? string.Empty, -+ ControlModelText = "SCL design • live verification required", -+ ControlValueType = ResolveControlValueType(dataObject.InferredCdc), -+ IsSelected = false, -+ Value = "-", -+ Quality = "Unknown", -+ DeviceTimestamp = "-", -+ ProbeStatus = "SCL control candidate; live verification required" -+ }); -+ } -+ -+ private static Dictionary BuildDataSetBindings(LiveIedModelDiscoveryDocument model) -+ { -+ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); -+ foreach (var dataSet in model.DataSets) -+ { -+ foreach (var member in dataSet.Members) -+ { -+ var key = NormalizeReference(member.Reference); -+ if (!string.IsNullOrWhiteSpace(key)) -+ bindings.TryAdd(key, dataSet.Reference); -+ } -+ } -+ return bindings; -+ } -+ -+ private static Dictionary BuildReportBindings(LiveIedModelDiscoveryDocument model) -+ { -+ var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); -+ foreach (var report in model.ReportControls) -+ { -+ var key = NormalizeReference(report.DataSetReference); -+ if (!string.IsNullOrWhiteSpace(key)) -+ bindings.TryAdd(key, report.Reference); -+ } -+ return bindings; -+ } -+ -+ private static LiveIedDataAttributeModel? FindCompanion(LiveIedDataObjectModel dataObject, string leaf) -+ => dataObject.Attributes.FirstOrDefault(attribute => Leaf(attribute.AttributePath).Equals(leaf, StringComparison.OrdinalIgnoreCase)); -+ -+ private static bool IsCompanion(string path) -+ { -+ var leaf = Leaf(path); -+ return leaf.Equals("q", StringComparison.OrdinalIgnoreCase) || -+ leaf.Equals("t", StringComparison.OrdinalIgnoreCase) || -+ leaf.Equals("ctlModel", StringComparison.OrdinalIgnoreCase); -+ } -+ -+ private static bool IsRuntimeLeaf(string path) -+ { -+ var leaf = Leaf(path); -+ if (RuntimeLeafNames.Contains(leaf)) -+ return true; -+ -+ var normalized = path.Replace('$', '.'); -+ return normalized.EndsWith("mag.f", StringComparison.OrdinalIgnoreCase) || -+ normalized.EndsWith("ang.f", StringComparison.OrdinalIgnoreCase) || -+ normalized.EndsWith("instMag.i", StringComparison.OrdinalIgnoreCase); -+ } -+ -+ private static string ResolveDataType(LiveIedDataAttributeModel attribute) -+ { -+ if (!string.IsNullOrWhiteSpace(attribute.SclBType)) -+ return attribute.SclBType; -+ if (!string.IsNullOrWhiteSpace(attribute.MmsType)) -+ return attribute.MmsType; -+ return "IEC 61850 value"; -+ } -+ -+ private static string ResolveCategory(string lnClass, string doName, string cdc, string fc) -+ { -+ if (fc.Equals("MX", StringComparison.OrdinalIgnoreCase)) -+ return "Measurement"; -+ if (doName.Equals("Pos", StringComparison.OrdinalIgnoreCase) || cdc.Equals("DPC", StringComparison.OrdinalIgnoreCase)) -+ return "Position"; -+ if (lnClass.StartsWith('P') || doName.Equals("Op", StringComparison.OrdinalIgnoreCase) || doName.Equals("Str", StringComparison.OrdinalIgnoreCase)) -+ return "Protection"; -+ return "Status"; -+ } -+ -+ private static string ResolveControlValueType(string cdc) -+ => (cdc ?? string.Empty).Trim().ToUpperInvariant() switch -+ { -+ "DPC" => "Dbpos", -+ "SPC" => "Boolean", -+ "INC" or "ISC" or "BSC" => "Int32", -+ "APC" or "BAC" => "Float32", -+ _ => string.Empty -+ }; -+ -+ private static string BuildDisplayName(string logicalNode, string dataObject, string attributePath) -+ => $"{logicalNode} {dataObject} {attributePath}"; -+ -+ private static string Leaf(string? path) -+ { -+ var text = (path ?? string.Empty).Replace('$', '.').Trim('.'); -+ var index = text.LastIndexOf('.'); -+ return index >= 0 ? text[(index + 1)..] : text; -+ } -+ -+ private static string NormalizeReference(string? reference) -+ { -+ var text = (reference ?? string.Empty).Trim(); -+ var fcMarker = text.LastIndexOf(" [", StringComparison.Ordinal); -+ if (fcMarker >= 0) -+ text = text[..fcMarker]; -+ text = text.Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim(); -+ -+ // SCL FCDA display references commonly use IED/LD/LN while MMS domains use IEDLD/LN. -+ var parts = text.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -+ if (parts.Length >= 3) -+ text = string.Concat(parts[0], parts[1], "/", string.Join("/", parts.Skip(2))); -+ -+ return text.ToUpperInvariant(); -+ } -+} diff --git a/.integration/monitormodels-1.patch b/.integration/monitormodels-1.patch deleted file mode 100644 index eb0fb001..00000000 --- a/.integration/monitormodels-1.patch +++ /dev/null @@ -1,84 +0,0 @@ ---- a/Models/MonitorModels.cs -+++ b/Models/MonitorModels.cs -@@ -1,3 +1,5 @@ -+using AR.Iec61850.Scl.Workspace; -+ - namespace ArIED61850Tester.Models; - - public sealed class Iec61850MonitorDevice : ObservableObject -@@ -28,12 +30,75 @@ - private bool _hasDiscoveryCache; - private string _busyTitle = "Discovering IEC 61850 IED"; - private int _unreadEventCount; -+ private SclIedWorkspace? _sclWorkspace; -+ private SclLiveModelComparisonResult? _sclComparison; -+ private string _sclSourcePath = string.Empty; -+ private string _sclSourceSha256 = string.Empty; -+ private string _sclIedName = string.Empty; -+ private string _sclAccessPointName = string.Empty; - - public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); - public BulkObservableCollection Signals { get; } = new(); - public BulkObservableCollection Points { get; } = new(); - public BulkObservableCollection CommandSignals { get; } = new(); - public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); -+ -+ public SclIedWorkspace? SclWorkspace -+ { -+ get => _sclWorkspace; -+ set -+ { -+ if (ReferenceEquals(_sclWorkspace, value)) return; -+ _sclWorkspace = value; -+ RefreshComputed(); -+ } -+ } -+ -+ public SclLiveModelComparisonResult? SclComparison -+ { -+ get => _sclComparison; -+ set -+ { -+ if (ReferenceEquals(_sclComparison, value)) return; -+ _sclComparison = value; -+ RefreshComputed(); -+ } -+ } -+ -+ public string SclSourcePath -+ { -+ get => _sclSourcePath; -+ set => Set(ref _sclSourcePath, value?.Trim() ?? string.Empty); -+ } -+ -+ public string SclSourceSha256 -+ { -+ get => _sclSourceSha256; -+ set => Set(ref _sclSourceSha256, value?.Trim() ?? string.Empty); -+ } -+ -+ public string SclIedName -+ { -+ get => _sclIedName; -+ set => Set(ref _sclIedName, value?.Trim() ?? string.Empty); -+ } -+ -+ public string SclAccessPointName -+ { -+ get => _sclAccessPointName; -+ set => Set(ref _sclAccessPointName, value?.Trim() ?? string.Empty); -+ } -+ -+ public bool HasSclDesignModel => SclWorkspace != null || !string.IsNullOrWhiteSpace(SclSourceSha256); -+ public bool RequiresEndpointBinding => HasSclDesignModel && string.IsNullOrWhiteSpace(IpAddress); -+ public bool HasSclConfigurationDrift => SclComparison?.RequiresFullDiscovery == true; -+ public string SclVerificationText => !HasSclDesignModel -+ ? string.Empty -+ : SclComparison == null -+ ? IsConnected ? "SCL associated • full model unverified" : "SCL offline model" -+ : SclComparison.IsCompatible -+ ? "SCL verified against live model" -+ : $"SCL drift • {SclComparison.BlockingFindingCount} blocking finding(s)"; - - // Smart Auto reporting: existing static RCB/DataSet first, temporary association- - // scoped dynamic DataSet/URCB second, MMS polling only when reporting cannot be armed. diff --git a/.integration/monitormodels-2.patch b/.integration/monitormodels-2.patch deleted file mode 100644 index 05607fad..00000000 --- a/.integration/monitormodels-2.patch +++ /dev/null @@ -1,103 +0,0 @@ ---- a/Models/MonitorModels.cs -+++ b/Models/MonitorModels.cs -@@ -320,28 +385,42 @@ - public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); - public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring); - public bool CanRescan => !IsBusy && !IsMonitoring; -- public string CacheStateText => HasDiscoveryCache -- ? $"Saved model ready • {SignalCount:N0} signals" -- : "No saved model • discovery required"; -- public string ReadyWorkspaceTitle => HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; -- public string ReadyWorkspaceMessage => HasDiscoveryCache && SelectedLiveSignalCount > 0 -- ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." -+ public string CacheStateText => HasSclDesignModel -+ ? $"SCL design model • {SignalCount:N0} signals" - : HasDiscoveryCache -- ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." -- : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; -+ ? $"Saved live model • {SignalCount:N0} signals" -+ : "No cached model • discovery required"; -+ public string ReadyWorkspaceTitle => HasSclDesignModel -+ ? RequiresEndpointBinding ? "SCL model ready — endpoint required" : "SCL design model is ready" -+ : HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; -+ public string ReadyWorkspaceMessage => HasSclDesignModel -+ ? RequiresEndpointBinding -+ ? "The LD/LN/DO/DA model is available offline. Press Play to bind an MMS endpoint before connecting." -+ : SelectedLiveSignalCount > 0 -+ ? $"{SelectedLiveSignalCount:N0} SCL signal(s) are selected. Play performs a fast MMS association; Re-scan compares the complete live model." -+ : "Browse and choose signals offline. Play associates without repeating full discovery; Re-scan performs design-versus-live verification." -+ : HasDiscoveryCache && SelectedLiveSignalCount > 0 -+ ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." -+ : HasDiscoveryCache -+ ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." -+ : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; - public string ConnectionActionLabel => IsBusy ? "Working…" : IsConnected ? "Disconnect" : "Connect"; - public string MonitorActionLabel => IsBusy ? "Working…" : IsMonitoring ? "Stop Monitor" : "Start Monitor"; - public string ActivityText => IsBusy ? "Working…" : IsMonitoring ? "Monitoring" : Status; - public string SummaryText => $"{SignalCount} scanned • {SelectedSignalCount} selected • {PointCount} live"; - public string IdentityText => string.IsNullOrWhiteSpace(LogicalDeviceSummary) - ? EndpointText -- : $"{EndpointText} • LD {LogicalDeviceSummary}"; -+ : HasSclDesignModel -+ ? $"{EndpointText} • {LogicalDeviceSummary}" -+ : $"{EndpointText} • LD {LogicalDeviceSummary}"; - public string ConnectionGlyph => IsBusy ? "…" : IsConnected ? "⏻" : "↗"; - public string ConnectionToolTip => IsBusy - ? "IED connection operation is running" - : IsConnected - ? $"Disconnect {Name}" -- : $"Connect and discover {EndpointText}"; -+ : HasSclDesignModel -+ ? $"Connect {Name} using the SCL design model" -+ : $"Connect and discover {EndpointText}"; - public string MonitorGlyph => IsBusy ? "…" : IsMonitoring ? "■" : "▶"; - public string MonitorToolTip => IsBusy - ? "IED session operation is running" -@@ -354,9 +433,13 @@ - ? $"Stop monitoring {Name} before changing its signal selection" - : $"Open signal selection wizard for {Name}"; - public string PlayToolTip => !IsConnected -- ? HasDiscoveryCache -- ? $"Fast-connect {Name} from the saved model and start its selected live values" -- : $"Connect and discover {EndpointText}" -+ ? RequiresEndpointBinding -+ ? $"Bind an MMS endpoint for {Name}, then fast-connect from the SCL design model" -+ : HasSclDesignModel -+ ? $"Fast-connect {Name} from the SCL design model; use Re-scan for full live comparison" -+ : HasDiscoveryCache -+ ? $"Fast-connect {Name} from the saved model and start its selected live values" -+ : $"Connect and discover {EndpointText}" - : IsMonitoring - ? $"{Name} is already monitoring" - : SelectedLiveSignalCount == 0 -@@ -436,6 +519,10 @@ - Raise(nameof(CanStopAction)); - Raise(nameof(CanRescan)); - Raise(nameof(CacheStateText)); -+ Raise(nameof(HasSclDesignModel)); -+ Raise(nameof(RequiresEndpointBinding)); -+ Raise(nameof(HasSclConfigurationDrift)); -+ Raise(nameof(SclVerificationText)); - Raise(nameof(ReadyWorkspaceTitle)); - Raise(nameof(ReadyWorkspaceMessage)); - Raise(nameof(ConnectionActionLabel)); -@@ -681,7 +768,7 @@ - - public sealed class Iec61850TesterProject - { -- public int SchemaVersion { get; set; } = 2; -+ public int SchemaVersion { get; set; } = 3; - public string ProjectName { get; set; } = "ArIED 61850 Session"; - public int DefaultPollingIntervalMs { get; set; } = 1000; - public List Devices { get; set; } = new(); -@@ -697,6 +784,10 @@ - public int Port { get; set; } = 102; - public bool AllowDynamicDataSetWrites { get; set; } = true; - public bool DiscoverySucceeded { get; set; } -+ public string SclSourcePath { get; set; } = string.Empty; -+ public string SclSourceSha256 { get; set; } = string.Empty; -+ public string SclIedName { get; set; } = string.Empty; -+ public string SclAccessPointName { get; set; } = string.Empty; - public List SelectedReferences { get; set; } = new(); - public List CachedSignals { get; set; } = new(); - } diff --git a/.integration/projection.patch b/.integration/projection.patch deleted file mode 100644 index 81b650e6..00000000 --- a/.integration/projection.patch +++ /dev/null @@ -1,300 +0,0 @@ ---- /dev/null -+++ b/Services/SclLiveSignalModelProjection.cs -@@ -0,0 +1,276 @@ -+using AR.Iec61850.Discovery; -+using ArIED61850Tester.Models; -+ -+namespace ArIED61850Tester.Services; -+ -+/// -+/// Builds a bounded engine discovery document from ArIED signal rows so the -+/// ARIEC61850 SCL comparer remains the only owner of design-versus-live rules. -+/// The projection is intentionally limited to model elements exposed by the -+/// application discovery workflow: DA references, FC/type evidence, DataSets, -+/// and ReportControl bindings. -+/// -+public static class SclLiveSignalModelProjection -+{ -+ public static LiveIedModelDiscoveryDocument Build( -+ string iedName, -+ string accessPointName, -+ IEnumerable signals) -+ { -+ var rows = (signals ?? Array.Empty()) -+ .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) -+ .ToArray(); -+ -+ var logicalDevices = rows -+ .Select(ToDescriptor) -+ .Where(descriptor => descriptor != null) -+ .Cast() -+ .GroupBy(descriptor => descriptor.Domain, StringComparer.OrdinalIgnoreCase) -+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) -+ .Select(domain => new LiveIedLogicalDeviceModel -+ { -+ MmsDomain = domain.Key, -+ Inst = LogicalDeviceInst(domain.Key, iedName), -+ LogicalNodes = domain -+ .GroupBy(descriptor => descriptor.LogicalNode, StringComparer.OrdinalIgnoreCase) -+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) -+ .Select(logicalNode => BuildLogicalNode(logicalNode.Key, logicalNode)) -+ .ToArray() -+ }) -+ .ToArray(); -+ -+ var dataSets = BuildDataSets(rows); -+ var reports = BuildReportControls(rows, dataSets); -+ var coverage = BuildCoverage(logicalDevices, dataSets, reports); -+ -+ return new LiveIedModelDiscoveryDocument -+ { -+ Source = "ArIEDSignalProjection", -+ IedName = iedName, -+ AccessPointName = accessPointName, -+ LogicalDevices = logicalDevices, -+ DataSets = dataSets, -+ ReportControls = reports, -+ Coverage = coverage, -+ Summary = $"ArIED runtime projection: LD={coverage.LogicalDeviceCount}, LN={coverage.LogicalNodeCount}, DO={coverage.DataObjectCount}, DA={coverage.DataAttributeCount}, RCB={coverage.ReportControlCount}, DataSet={coverage.DataSetCount}." -+ }; -+ } -+ -+ private static LiveIedLogicalNodeModel BuildLogicalNode( -+ string logicalNodeName, -+ IEnumerable descriptors) -+ { -+ var descriptorArray = descriptors.ToArray(); -+ var parts = SignalDefinition.DetectLogicalNodeClass(logicalNodeName); -+ var dataObjects = descriptorArray -+ .GroupBy(descriptor => descriptor.DataObject, StringComparer.OrdinalIgnoreCase) -+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) -+ .Select(group => BuildDataObject(group.Key, group)) -+ .ToArray(); -+ -+ return new LiveIedLogicalNodeModel -+ { -+ Name = logicalNodeName, -+ LnClass = parts, -+ ProposedLnTypeId = $"ARIED_{SafeId(parts)}_{SafeId(logicalNodeName)}", -+ FunctionalConstraintCounts = dataObjects -+ .SelectMany(dataObject => dataObject.Attributes) -+ .Where(attribute => !string.IsNullOrWhiteSpace(attribute.FunctionalConstraint)) -+ .GroupBy(attribute => attribute.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) -+ .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase), -+ DataObjects = dataObjects -+ }; -+ } -+ -+ private static LiveIedDataObjectModel BuildDataObject( -+ string dataObjectName, -+ IEnumerable descriptors) -+ { -+ var descriptorArray = descriptors.ToArray(); -+ var primary = descriptorArray.First(); -+ var attributes = descriptorArray -+ .Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.AttributePath)) -+ .GroupBy(descriptor => NormalizeReference(descriptor.Reference), StringComparer.OrdinalIgnoreCase) -+ .Select(group => group.First()) -+ .OrderBy(descriptor => descriptor.AttributePath, StringComparer.OrdinalIgnoreCase) -+ .Select(descriptor => new LiveIedDataAttributeModel -+ { -+ ObjectReference = descriptor.Reference, -+ AttributePath = descriptor.AttributePath, -+ FunctionalConstraint = descriptor.FunctionalConstraint, -+ MmsReference = descriptor.Reference, -+ MmsItemName = descriptor.Reference.Contains('/') -+ ? descriptor.Reference[(descriptor.Reference.IndexOf('/') + 1)..] -+ : descriptor.Reference, -+ Source = "ArIED signal model", -+ SclBType = descriptor.DataType, -+ MmsType = descriptor.DataType, -+ MmsTypeSignature = descriptor.DataType, -+ TypeDiscoveryStatus = "Projected", -+ TypeDiscoveryMessage = "Projected from the ArIED discovery signal row.", -+ TypeSource = "ArIED discovery", -+ TypeConfidence = LiveIedDiscoveryConfidenceLevel.High, -+ FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(descriptor.FunctionalConstraint) -+ ? LiveIedDiscoveryConfidenceLevel.Unknown -+ : LiveIedDiscoveryConfidenceLevel.Exact -+ }) -+ .ToArray(); -+ -+ return new LiveIedDataObjectModel -+ { -+ Reference = primary.ObjectReference, -+ Name = dataObjectName, -+ ProposedDoTypeId = $"ARIED_DO_{SafeId(primary.Cdc)}_{SafeId(dataObjectName)}", -+ InferredCdc = primary.Cdc, -+ CdcConfidence = string.IsNullOrWhiteSpace(primary.Cdc) ? 0.5 : 0.9, -+ ConfidenceLevel = string.IsNullOrWhiteSpace(primary.Cdc) -+ ? LiveIedDiscoveryConfidenceLevel.Medium -+ : LiveIedDiscoveryConfidenceLevel.High, -+ Evidence = new[] { "Projected from ArIED live discovery signal metadata." }, -+ Attributes = attributes -+ }; -+ } -+ -+ private static IReadOnlyList BuildDataSets(IReadOnlyList signals) -+ => signals -+ .Where(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) -+ .GroupBy(signal => signal.DataSetReference.Trim(), StringComparer.OrdinalIgnoreCase) -+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) -+ .Select(group => -+ { -+ var reference = group.Key; -+ var domain = Domain(reference); -+ var tail = ReferenceTail(reference); -+ var separator = tail.LastIndexOf('.'); -+ var logicalNode = separator > 0 ? tail[..separator] : string.Empty; -+ var name = separator > 0 ? tail[(separator + 1)..] : tail; -+ var members = group -+ .Where(signal => !signal.IsControlSignal) -+ .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) -+ .Select(values => values.First()) -+ .Select((signal, index) => new LiveIedDataSetMemberModel -+ { -+ Index = index + 1, -+ Reference = signal.ObjectReference, -+ FunctionalConstraint = signal.FunctionalConstraint, -+ MmsReference = signal.ObjectReference, -+ Confidence = LiveIedDiscoveryConfidenceLevel.High -+ }) -+ .ToArray(); -+ -+ return new LiveIedDataSetModel -+ { -+ Reference = reference, -+ Domain = domain, -+ LogicalNode = logicalNode, -+ Name = name, -+ MemberCount = members.Length, -+ Members = members -+ }; -+ }) -+ .ToArray(); -+ -+ private static IReadOnlyList BuildReportControls( -+ IReadOnlyList signals, -+ IReadOnlyList dataSets) -+ { -+ var dataSetIndex = dataSets.ToDictionary( -+ dataSet => NormalizeReference(dataSet.Reference), -+ dataSet => dataSet, -+ StringComparer.OrdinalIgnoreCase); -+ -+ return signals -+ .Where(signal => !string.IsNullOrWhiteSpace(signal.ReportControlReference)) -+ .GroupBy(signal => signal.ReportControlReference.Trim(), StringComparer.OrdinalIgnoreCase) -+ .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) -+ .Select(group => -+ { -+ var first = group.First(); -+ var reference = group.Key; -+ var tail = ReferenceTail(reference); -+ var name = tail.Contains('.') ? tail[(tail.LastIndexOf('.') + 1)..] : tail; -+ dataSetIndex.TryGetValue(NormalizeReference(first.DataSetReference), out var dataSet); -+ return new LiveIedReportControlModel -+ { -+ Reference = reference, -+ Domain = Domain(reference), -+ LogicalNode = tail.Contains('.') ? tail[..tail.IndexOf('.')] : "LLN0", -+ Name = name, -+ Buffered = NormalizeReference(reference).Contains(".BR.", StringComparison.OrdinalIgnoreCase), -+ DataSetReference = dataSet?.Reference ?? first.DataSetReference, -+ Status = "Projected from ArIED discovery" -+ }; -+ }) -+ .ToArray(); -+ } -+ -+ private static LiveIedModelDiscoveryCoverage BuildCoverage( -+ IReadOnlyList logicalDevices, -+ IReadOnlyList dataSets, -+ IReadOnlyList reports) -+ { -+ var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray(); -+ var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray(); -+ var attributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray(); -+ return new LiveIedModelDiscoveryCoverage -+ { -+ LogicalDeviceCount = logicalDevices.Count, -+ LogicalNodeCount = logicalNodes.Length, -+ DataObjectCount = dataObjects.Length, -+ DataAttributeCount = attributes.Length, -+ ExactFunctionalConstraintCount = attributes.Count(attribute => attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact), -+ ExactMmsTypeCount = attributes.Count(attribute => !string.IsNullOrWhiteSpace(attribute.MmsType)), -+ HighConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High), -+ MediumConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel == LiveIedDiscoveryConfidenceLevel.Medium), -+ DataSetCount = dataSets.Count, -+ ReportControlCount = reports.Count, -+ BufferedReportControlCount = reports.Count(report => report.Buffered), -+ UnbufferedReportControlCount = reports.Count(report => !report.Buffered) -+ }; -+ } -+ -+ private static SignalDescriptor? ToDescriptor(SignalDefinition signal) -+ { -+ var reference = signal.ObjectReference.Replace('$', '.').Trim(); -+ var slash = reference.IndexOf('/'); -+ if (slash <= 0 || slash >= reference.Length - 1) -+ return null; -+ -+ var domain = reference[..slash]; -+ var member = reference[(slash + 1)..]; -+ var segments = member.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -+ if (segments.Length < 2) -+ return null; -+ -+ var logicalNode = segments[0]; -+ var dataObject = segments[1]; -+ var attributePath = segments.Length > 2 ? string.Join('.', segments.Skip(2)) : string.Empty; -+ if (signal.IsControlSignal && string.IsNullOrWhiteSpace(attributePath)) -+ attributePath = "Oper.ctlVal"; -+ -+ return new SignalDescriptor( -+ domain, -+ logicalNode, -+ dataObject, -+ attributePath, -+ reference, -+ $"{domain}/{logicalNode}.{dataObject}", -+ signal.FunctionalConstraint, -+ signal.DataType, -+ signal.ControlCdc); -+ } -+ -+ private static string Domain(string reference) -+ { -+ var slash = reference.IndexOf('/'); -+ return slash > 0 ? reference[..slash] : string.Empty; -+ } -+ -+ private static string ReferenceTail(string reference) -+ { -+ var text = reference.Replace('$', '.'); -+ var slash = text.IndexOf('/'); -+ return slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text; -+ } -+ -+ private static string LogicalDeviceInst(string domain, string iedName) -+ => !string.IsNullOrWhiteSpace(iedName) && domain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) -+ ? domain[iedName.Length..] -+ : domain; -+ -+ private static string NormalizeReference(string? reference) -+ => (reference ?? string.Empty).Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim().ToUpperInvariant(); -+ -+ private static string SafeId(string? value) -+ => string.Concat((value ?? string.Empty).Where(character => char.IsLetterOrDigit(character) || character == '_')); -+ -+ private sealed record SignalDescriptor( -+ string Domain, -+ string LogicalNode, -+ string DataObject, -+ string AttributePath, -+ string Reference, -+ string ObjectReference, -+ string FunctionalConstraint, -+ string DataType, -+ string Cdc); -+} diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 86660102..b1e2d95f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -11,6 +11,7 @@ using System.Windows.Threading; using System.Windows.Media; using System.Windows.Media.Animation; +using AR.Iec61850.Scl.Workspace; using ArIED61850Tester.Models; using ArIED61850Tester.Services; using Microsoft.Win32; @@ -20,6 +21,7 @@ namespace ArIED61850Tester; public partial class MainWindow : Window, INotifyPropertyChanged { private readonly Iec61850MonitorRuntime _runtime = new(); + private readonly SclWorkspaceService _sclWorkspaceService = new(); private readonly CancellationTokenSource _applicationCancellation = new(); private readonly Dictionary> _pendingProjectSelections = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _signalOwners = new(); @@ -160,28 +162,18 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) return; var sourceName = Path.GetFileName(dialog.FileName); - SetStatus($"Reading IED endpoints from {sourceName}…"); + SetStatus($"Opening {sourceName} as an offline IEC 61850 design model…"); try { - var result = await SclImportService.LoadAsync(dialog.FileName, _applicationCancellation.Token); - foreach (var warning in result.Warnings.Take(25)) - AddLog("WARN", "SCL", warning); - if (result.Warnings.Count > 25) - AddLog("WARN", "SCL", $"{result.Warnings.Count - 25} additional SCL warning(s) were omitted from the live log."); + var document = await _sclWorkspaceService.OpenAsync( + dialog.FileName, + cancellationToken: _applicationCancellation.Token); + LogSclFindings(sourceName, document.Findings); - if (result.Endpoints.Count == 0) + if (document.Ieds.Count == 0) { - var reason = result.ConnectedAccessPointCount == 0 - ? "No ConnectedAP communication entries were found." - : "ConnectedAP entries were found, but none contained a valid IP address."; - SetStatus($"{sourceName}: no usable IEC 61850 MMS endpoints. {reason}"); - AddLog("WARN", "SCL", $"{sourceName}: {reason}"); - MessageBox.Show( - this, - $"No usable IEC 61850 MMS endpoint was found in {sourceName}.\n\n{reason}\n\nThe file may contain only an IED template without a Communication section.", - "Open SCL", - MessageBoxButton.OK, - MessageBoxImage.Information); + SetStatus($"{sourceName}: no IED model was found."); + AddLog("WARN", "SCL", $"{sourceName}: the engine returned no IED workspace."); return; } @@ -190,51 +182,33 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) var retained = 0; Iec61850MonitorDevice? firstImported = null; - foreach (var endpoint in result.Endpoints) + foreach (var workspace in document.Ieds) { var device = Devices.FirstOrDefault(item => - item.IpAddress.Equals(endpoint.IpAddress, StringComparison.OrdinalIgnoreCase) && - item.Port == endpoint.Port); + item.SclSourceSha256.Equals(document.SourceSha256, StringComparison.OrdinalIgnoreCase) && + item.SclIedName.Equals(workspace.IedName, StringComparison.OrdinalIgnoreCase) && + item.SclAccessPointName.Equals(workspace.AccessPointName, StringComparison.OrdinalIgnoreCase)); + if (device != null && (device.IsConnected || device.IsBusy || device.IsMonitoring)) + { + retained++; + firstImported ??= device; + continue; + } + + var signals = SclWorkspaceSignalMapper.BuildSignals(workspace); if (device == null) { - device = new Iec61850MonitorDevice - { - Name = endpoint.IedName, - IdentitySource = $"SCL • {sourceName}", - LogicalDeviceSummary = BuildSclEndpointSummary(endpoint), - IpAddress = endpoint.IpAddress, - Port = endpoint.Port, - AllowDynamicDataSetWrites = true, - Status = "SCL endpoint ready", - Detail = $"Imported from {sourceName}. Press Play to connect and verify the live IEC 61850 model.", - AcquisitionMode = "SCL • live discovery pending" - }; + device = new Iec61850MonitorDevice(); Devices.Add(device); added++; } - else if (!device.IsConnected && !device.IsBusy && !device.HasDiscoveryCache) - { - if (string.IsNullOrWhiteSpace(device.Name) || - device.Name.Equals(device.IpAddress, StringComparison.OrdinalIgnoreCase)) - { - device.Name = endpoint.IedName; - } - device.IdentitySource = $"SCL • {sourceName}"; - device.LogicalDeviceSummary = BuildSclEndpointSummary(endpoint); - device.Status = "SCL endpoint ready"; - device.Detail = $"Endpoint refreshed from {sourceName}. Press Play to connect and verify the live IEC 61850 model."; - device.AcquisitionMode = "SCL • live discovery pending"; - device.RefreshComputed(); - refreshed++; - } else { - // Preserve active sessions and successful discovery caches. SCL is an - // endpoint-import path, never authority over a verified live model. - retained++; + refreshed++; } + ApplySclWorkspaceToDevice(device, document, workspace, signals); firstImported ??= device; } @@ -244,39 +218,130 @@ private async void OpenScl_Click(object sender, RoutedEventArgs e) UpdateNavigationVisuals(0, animate: true); RaiseWorkspaceCounts(); - var warningText = result.Warnings.Count == 0 ? string.Empty : $", {result.Warnings.Count} warning(s)"; - var status = $"{sourceName}: {result.Endpoints.Count} SCL endpoint(s) read — {added} added, {refreshed} refreshed, {retained} existing retained{warningText}. Use Play or Connect All for live verification."; + var offlineCount = document.Ieds.Count(item => item.CanBrowseOffline); + var endpointCount = document.Ieds.Count(item => !item.RequiresEndpointBinding); + var status = $"{sourceName}: {document.Ieds.Count} IED/AP workspace(s), {offlineCount} offline model(s), {endpointCount} MMS endpoint(s) — {added} added, {refreshed} refreshed, {retained} active retained."; SetStatus(status); AddLog("INFO", "SCL", status); } catch (OperationCanceledException) { - SetStatus($"{sourceName}: SCL import cancelled."); + SetStatus($"{sourceName}: SCL open cancelled."); } catch (Exception ex) { AddLog("ERROR", "SCL", $"Could not open {sourceName}: {ex.Message}"); - SetStatus($"{sourceName}: SCL import failed. Diagnostics is marked with !."); + SetStatus($"{sourceName}: SCL open failed. Diagnostics is marked with !."); MarkDiagnosticAlert(); MessageBox.Show( this, - $"ArIED could not read this SCL file.\n\n{ex.Message}", + $"ArIED could not open this SCL file through the ARIEC61850 engine.\n\n{ex.Message}", "Open SCL", MessageBoxButton.OK, MessageBoxImage.Error); } } - private static string BuildSclEndpointSummary(SclIedEndpoint endpoint) + private void ApplySclWorkspaceToDevice( + Iec61850MonitorDevice device, + SclWorkspaceDocument document, + SclIedWorkspace workspace, + IReadOnlyList signals) { - var parts = new List(); - if (!string.IsNullOrWhiteSpace(endpoint.AccessPointName)) - parts.Add($"AP {endpoint.AccessPointName}"); - if (!string.IsNullOrWhiteSpace(endpoint.SubNetworkName)) - parts.Add(endpoint.SubNetworkName); - return parts.Count == 0 ? "SCL endpoint" : string.Join(" • ", parts); + var previousSelection = device.Signals + .Where(signal => signal.IsSelected) + .Select(signal => NormalizeReference(signal.ObjectReference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + DetachSignalHandlers(device.Signals); + device.Signals.Clear(); + device.RecountSelectedSignals(); + + var endpoint = workspace.PreferredEndpoint; + device.Name = workspace.IedName; + device.IdentitySource = $"SCL design • {document.SourceName}"; + device.LogicalDeviceSummary = BuildSclWorkspaceSummary(workspace); + if (endpoint?.HasUsableAddress == true) + { + device.IpAddress = endpoint.IpAddress; + device.Port = endpoint.Port; + } + else if (string.IsNullOrWhiteSpace(device.IpAddress) || device.IpAddress == "192.168.1.10") + { + device.IpAddress = string.Empty; + device.Port = 102; + } + + device.AllowDynamicDataSetWrites = false; + device.SclWorkspace = workspace; + device.SclComparison = null; + device.SclSourcePath = document.SourcePath; + device.SclSourceSha256 = document.SourceSha256; + device.SclIedName = workspace.IedName; + device.SclAccessPointName = workspace.AccessPointName; + device.HasDiscoveryCache = signals.Count > 0; + device.Status = workspace.RequiresEndpointBinding ? "SCL model ready — bind endpoint" : "SCL model ready"; + device.Detail = workspace.RequiresEndpointBinding + ? "LD/LN/DO/DA are available offline. Press Play to bind an MMS endpoint; no discovery traffic was sent while opening the file." + : "LD/LN/DO/DA were loaded offline. Play performs a fast MMS association; Re-scan performs full design-versus-live verification."; + device.AcquisitionMode = "SCL offline design model"; + + foreach (var signal in signals) + { + signal.IsSelected = previousSelection.Contains(NormalizeReference(signal.ObjectReference)); + signal.PropertyChanged += Signal_PropertyChanged; + _signalOwners[signal] = device; + } + device.Signals.AddRange(signals); + device.RecountSelectedSignals(); + device.RefreshComputed(); + } + + private static string BuildSclWorkspaceSummary(SclIedWorkspace workspace) + { + var coverage = workspace.DesignModel.Coverage; + var ap = string.IsNullOrWhiteSpace(workspace.AccessPointName) ? "AP unassigned" : $"AP {workspace.AccessPointName}"; + return $"{ap} • {coverage.LogicalDeviceCount} LD • {coverage.LogicalNodeCount} LN • {coverage.DataObjectCount} DO • {coverage.DataAttributeCount} DA"; } + private void LogSclFindings(string sourceName, IReadOnlyList findings) + { + foreach (var finding in findings.Take(40)) + { + var level = finding.Severity.Equals("High", StringComparison.OrdinalIgnoreCase) || + finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) + ? "ERROR" + : finding.Severity.Equals("Warning", StringComparison.OrdinalIgnoreCase) ? "WARN" : "INFO"; + AddLog(level, "SCL", $"{sourceName} • {finding.Code}: {finding.Message}"); + } + if (findings.Count > 40) + AddLog("WARN", "SCL", $"{findings.Count - 40} additional finding(s) were omitted from the live log."); + if (findings.Any(finding => finding.Severity is "High" or "Error")) + MarkDiagnosticAlert(); + } + + private bool EnsureSclEndpointBinding(Iec61850MonitorDevice device) + { + if (!device.RequiresEndpointBinding) + return true; + + var initialIp = string.IsNullOrWhiteSpace(NewDeviceIp) ? "192.168.1.10" : NewDeviceIp; + var wizard = new IpConnectWizardWindow(initialIp, device.Port <= 0 ? 102 : device.Port) { Owner = this }; + if (wizard.ShowDialog() != true) + { + SetStatus($"{device.Name}: endpoint binding cancelled; the SCL model remains available offline."); + return false; + } + + device.IpAddress = wizard.RelayIpAddress; + device.Port = wizard.MmsPort; + device.Status = "SCL model ready"; + device.Detail = "Endpoint bound locally. Play will fast-connect from the SCL design model; Re-scan performs full comparison."; + device.RefreshComputed(); + NewDeviceIp = device.IpAddress; + NewDevicePort = device.Port.ToString(CultureInfo.InvariantCulture); + return true; + } private async void ConnectAllIeds_Click(object sender, RoutedEventArgs e) { @@ -326,6 +391,14 @@ private async Task ConnectAndStartWorkspaceDeviceAsync(Iec61850MonitorDevi { if (device.IsMonitoring) return true; + if (device.RequiresEndpointBinding) + { + device.Status = "SCL model ready — endpoint required"; + device.Detail = "Connect All skipped this offline SCL workspace because no MMS endpoint is bound."; + device.RefreshComputed(); + AddLog("WARN", device.Name, "Connect All skipped the SCL workspace because its MMS endpoint is unassigned."); + return false; + } var connected = device.IsConnected; if (!connected) @@ -448,6 +521,7 @@ private async Task ConnectAndConfigureDeviceAsync( bool selectDevice = true) { if (device.IsBusy) return false; + if (!EnsureSclEndpointBinding(device)) return false; RememberCurrentSelectionForReconnect(device); RemoveDevicePoints(device.DeviceId); @@ -482,6 +556,7 @@ private async Task ConnectAndConfigureDeviceAsync( } device.Signals.AddRange(signals); device.HasDiscoveryCache = signals.Count > 0; + ApplySclLiveComparison(device, signals); try { @@ -576,6 +651,7 @@ private async Task ConnectUsingSavedModelAsync( bool selectDevice = true) { if (device.IsBusy) return false; + if (!EnsureSclEndpointBinding(device)) return false; if (!device.HasDiscoveryCache || device.Signals.Count == 0) return await ConnectAndConfigureDeviceAsync(device, openWizard: true, selectDevice: selectDevice); @@ -601,7 +677,17 @@ await _runtime.ConnectUsingCachedModelAsync( device.RecountSelectedSignals(); await WaitForDiscoveryProgressAnimationAsync(device, TimeSpan.FromMilliseconds(900)); RaiseWorkspaceCounts(); - SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); + if (device.HasSclDesignModel) + { + device.Status = "Connected — SCL design model"; + device.Detail = "MMS association is live and the SCL workspace remains the active model. Re-scan performs a complete design-versus-live comparison."; + device.AcquisitionMode = "SCL design model • live association"; + SetStatus($"{device.Name}: fast connected from the SCL design model; full discovery skipped. Use Re-scan to compare the complete live model."); + } + else + { + SetStatus($"{device.Name}: fast connected from saved project model; full discovery skipped."); + } return true; } catch (OperationCanceledException) @@ -644,6 +730,47 @@ await _runtime.ConnectUsingCachedModelAsync( } } + private void ApplySclLiveComparison(Iec61850MonitorDevice device, IReadOnlyList liveSignals) + { + if (device.SclWorkspace == null) + return; + + var expectedModel = SclLiveSignalModelProjection.Build( + device.SclWorkspace.IedName, + device.SclWorkspace.AccessPointName, + SclWorkspaceSignalMapper.BuildSignals(device.SclWorkspace)); + var observedModel = SclLiveSignalModelProjection.Build( + device.Name, + device.SclWorkspace.AccessPointName, + liveSignals); + var comparison = SclLiveModelComparer.Compare(expectedModel, observedModel); + device.SclComparison = comparison; + foreach (var finding in comparison.Findings.Take(30)) + { + var level = finding.Severity.Equals("Error", StringComparison.OrdinalIgnoreCase) ? "ERROR" : "INFO"; + AddLog(level, "SCL Compare", $"{finding.Kind} • {finding.Message}"); + } + if (comparison.Findings.Count > 30) + AddLog("WARN", "SCL Compare", $"{comparison.Findings.Count - 30} additional comparison finding(s) were omitted from the live log."); + + if (comparison.IsCompatible) + { + device.IdentitySource = $"SCL + live verified • {Path.GetFileName(device.SclSourcePath)}"; + device.AcquisitionMode = "SCL design • live model verified"; + device.Detail = $"SCL and live MMS structures are compatible: {comparison.MatchedAttributeCount}/{comparison.ExpectedAttributeCount} expected attributes matched."; + AddLog("INFO", device.Name, device.Detail); + } + else + { + device.IdentitySource = $"SCL drift detected • {Path.GetFileName(device.SclSourcePath)}"; + device.AcquisitionMode = "Live discovery • SCL configuration drift"; + device.Detail = $"Live discovery found {comparison.BlockingFindingCount} blocking SCL mismatch(es). Live data is shown; review Diagnostics before testing control or reporting."; + MarkDiagnosticAlert(); + AddLog("ERROR", device.Name, device.Detail); + } + device.RefreshComputed(); + } + private async Task OpenSignalSelectionWizardAsync( Iec61850MonitorDevice device, int restoredSelectionCount = -1, @@ -1102,11 +1229,15 @@ private async void IedRescan_Click(object sender, RoutedEventArgs e) return; SelectedDevice = device; + if (!EnsureSclEndpointBinding(device)) + return; RememberCurrentSelectionForReconnect(device); if (device.IsConnected) await StopDeviceConnectionAsync(device); - SetStatus($"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); + SetStatus(device.HasSclDesignModel + ? $"{device.Name}: discovering the complete live model and comparing it with the SCL design model." + : $"{device.Name}: running a forced full live-model discovery. The saved cache will be replaced only after success."); await ConnectAndConfigureDeviceAsync(device, openWizard: false); } @@ -1395,6 +1526,10 @@ private async void SaveProject_Click(object sender, RoutedEventArgs e) Port = device.Port, AllowDynamicDataSetWrites = device.AllowDynamicDataSetWrites, DiscoverySucceeded = device.HasDiscoveryCache && device.Signals.Count > 0, + SclSourcePath = device.SclSourcePath, + SclSourceSha256 = device.SclSourceSha256, + SclIedName = device.SclIedName, + SclAccessPointName = device.SclAccessPointName, SelectedReferences = device.Signals .Where(signal => signal.IsSelected) .Select(signal => NormalizeReference(signal.ObjectReference)) @@ -1459,6 +1594,7 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e) foreach (var profile in project.Devices ?? new List()) { + var restoredSclWorkspace = await TryRestoreSclWorkspaceAsync(profile); var cachedSignals = (profile.CachedSignals ?? new List()) .Where(item => !string.IsNullOrWhiteSpace(item.ObjectReference)) .Select(item => item.ToSignal()) @@ -1466,11 +1602,14 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e) .GroupBy(item => NormalizeReference(item.ObjectReference), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .ToList(); + if (restoredSclWorkspace != null) + cachedSignals = SclWorkspaceSignalMapper.BuildSignals(restoredSclWorkspace).ToList(); var selectedReferences = (profile.SelectedReferences ?? new List()) .Select(NormalizeReference) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var hasSavedModel = profile.DiscoverySucceeded && cachedSignals.Count > 0; + var hasSclProvenance = restoredSclWorkspace != null || !string.IsNullOrWhiteSpace(profile.SclSourceSha256); + var hasSavedModel = (profile.DiscoverySucceeded || hasSclProvenance) && cachedSignals.Count > 0; var device = new Iec61850MonitorDevice { DeviceId = string.IsNullOrWhiteSpace(profile.DeviceId) ? Guid.NewGuid().ToString("N") : profile.DeviceId, @@ -1479,13 +1618,24 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e) LogicalDeviceSummary = profile.LogicalDeviceSummary, IpAddress = profile.IpAddress, Port = profile.Port <= 0 ? 102 : profile.Port, - AllowDynamicDataSetWrites = profile.AllowDynamicDataSetWrites, + AllowDynamicDataSetWrites = hasSclProvenance ? false : profile.AllowDynamicDataSetWrites, + SclWorkspace = restoredSclWorkspace, + SclSourcePath = profile.SclSourcePath, + SclSourceSha256 = profile.SclSourceSha256, + SclIedName = profile.SclIedName, + SclAccessPointName = profile.SclAccessPointName, HasDiscoveryCache = hasSavedModel, - Status = hasSavedModel ? "Saved model ready" : "Discovery required", - Detail = hasSavedModel - ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." - : "This IED has no successful saved discovery. Press Play to scan the live model.", - AcquisitionMode = hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" + Status = hasSclProvenance + ? string.IsNullOrWhiteSpace(profile.IpAddress) ? "SCL model ready — bind endpoint" : "SCL model ready" + : hasSavedModel ? "Saved model ready" : "Discovery required", + Detail = hasSclProvenance + ? "SCL design model restored from project provenance. Play fast-connects; Re-scan compares the full live model." + : hasSavedModel + ? "Press Play for fast connect and live values. Full signal discovery is skipped unless Re-scan is selected." + : "This IED has no successful saved discovery. Press Play to scan the live model.", + AcquisitionMode = hasSclProvenance + ? "SCL project model • offline" + : hasSavedModel ? "Saved model • fast connect" : "Not connected • scan required" }; Devices.Add(device); @@ -1509,7 +1659,8 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e) SelectedDevice = Devices.FirstOrDefault(); RaiseWorkspaceCounts(); var cachedCount = Devices.Count(device => device.HasDiscoveryCache); - SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} saved discovery model(s) ready for fast Play connect without a full scan."); + var sclCount = Devices.Count(device => device.HasSclDesignModel); + SetStatus($"Project loaded: {Devices.Count} IED profile(s), {cachedCount} cached model(s), {sclCount} SCL design model(s) ready for offline browsing and fast Play connect."); } catch (Exception ex) { @@ -1518,6 +1669,44 @@ private async void OpenProject_Click(object sender, RoutedEventArgs e) } } + private async Task TryRestoreSclWorkspaceAsync(Iec61850TesterDeviceProfile profile) + { + if (string.IsNullOrWhiteSpace(profile.SclSourcePath) || string.IsNullOrWhiteSpace(profile.SclSourceSha256)) + return null; + if (!File.Exists(profile.SclSourcePath)) + { + AddLog("WARN", "SCL", $"Saved SCL source is unavailable: {profile.SclSourcePath}. The cached signal model remains usable."); + return null; + } + + try + { + var document = await _sclWorkspaceService.OpenAsync( + profile.SclSourcePath, + new SclWorkspaceOpenOptions + { + IedName = profile.SclIedName, + AccessPointName = profile.SclAccessPointName + }, + _applicationCancellation.Token); + if (!document.SourceSha256.Equals(profile.SclSourceSha256, StringComparison.OrdinalIgnoreCase)) + { + AddLog("ERROR", "SCL", $"Saved SCL source changed on disk: {profile.SclSourcePath}. Cached signals were retained and the changed file was not trusted automatically."); + MarkDiagnosticAlert(); + return null; + } + + return document.Ieds.FirstOrDefault(item => + (string.IsNullOrWhiteSpace(profile.SclIedName) || item.IedName.Equals(profile.SclIedName, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrWhiteSpace(profile.SclAccessPointName) || item.AccessPointName.Equals(profile.SclAccessPointName, StringComparison.OrdinalIgnoreCase))); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + AddLog("WARN", "SCL", $"Could not restore {profile.SclSourcePath}: {ex.Message}. Cached signals were retained."); + return null; + } + } + private void ExportEvents_Click(object sender, RoutedEventArgs e) { if (Events.Count == 0) diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 959b66cc..6dc27ab8 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -1,3 +1,5 @@ +using AR.Iec61850.Scl.Workspace; + namespace ArIED61850Tester.Models; public sealed class Iec61850MonitorDevice : ObservableObject @@ -28,6 +30,12 @@ public sealed class Iec61850MonitorDevice : ObservableObject private bool _hasDiscoveryCache; private string _busyTitle = "Discovering IEC 61850 IED"; private int _unreadEventCount; + private SclIedWorkspace? _sclWorkspace; + private SclLiveModelComparisonResult? _sclComparison; + private string _sclSourcePath = string.Empty; + private string _sclSourceSha256 = string.Empty; + private string _sclIedName = string.Empty; + private string _sclAccessPointName = string.Empty; public string DeviceId { get; set; } = Guid.NewGuid().ToString("N"); public BulkObservableCollection Signals { get; } = new(); @@ -35,6 +43,63 @@ public sealed class Iec61850MonitorDevice : ObservableObject public BulkObservableCollection CommandSignals { get; } = new(); public Iec61850DeviceDiagnosticSnapshot LastDiagnosticSnapshot { get; set; } = new(); + public SclIedWorkspace? SclWorkspace + { + get => _sclWorkspace; + set + { + if (ReferenceEquals(_sclWorkspace, value)) return; + _sclWorkspace = value; + RefreshComputed(); + } + } + + public SclLiveModelComparisonResult? SclComparison + { + get => _sclComparison; + set + { + if (ReferenceEquals(_sclComparison, value)) return; + _sclComparison = value; + RefreshComputed(); + } + } + + public string SclSourcePath + { + get => _sclSourcePath; + set => Set(ref _sclSourcePath, value?.Trim() ?? string.Empty); + } + + public string SclSourceSha256 + { + get => _sclSourceSha256; + set => Set(ref _sclSourceSha256, value?.Trim() ?? string.Empty); + } + + public string SclIedName + { + get => _sclIedName; + set => Set(ref _sclIedName, value?.Trim() ?? string.Empty); + } + + public string SclAccessPointName + { + get => _sclAccessPointName; + set => Set(ref _sclAccessPointName, value?.Trim() ?? string.Empty); + } + + public bool HasSclDesignModel => SclWorkspace != null || !string.IsNullOrWhiteSpace(SclSourceSha256); + public bool RequiresEndpointBinding => HasSclDesignModel && string.IsNullOrWhiteSpace(IpAddress); + public bool HasSclConfigurationDrift => SclComparison?.RequiresFullDiscovery == true; + public string SclVerificationText => !HasSclDesignModel + ? string.Empty + : SclComparison == null + ? IsConnected ? "SCL associated • full model unverified" : "SCL offline model" + : SclComparison.IsCompatible + ? "SCL verified against live model" + : $"SCL drift • {SclComparison.BlockingFindingCount} blocking finding(s)"; + // Smart Auto reporting: existing static RCB/DataSet first, temporary association- // scoped dynamic DataSet/URCB second, MMS polling only when reporting cannot be armed. public bool AllowDynamicDataSetWrites { get; set; } = true; @@ -320,28 +385,42 @@ public void MarkDiscoveryFailed(string message) public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring); public bool CanRescan => !IsBusy && !IsMonitoring; - public string CacheStateText => HasDiscoveryCache - ? $"Saved model ready • {SignalCount:N0} signals" - : "No saved model • discovery required"; - public string ReadyWorkspaceTitle => HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; - public string ReadyWorkspaceMessage => HasDiscoveryCache && SelectedLiveSignalCount > 0 - ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." + public string CacheStateText => HasSclDesignModel + ? $"SCL design model • {SignalCount:N0} signals" : HasDiscoveryCache - ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." - : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; + ? $"Saved live model • {SignalCount:N0} signals" + : "No cached model • discovery required"; + public string ReadyWorkspaceTitle => HasSclDesignModel + ? RequiresEndpointBinding ? "SCL model ready — endpoint required" : "SCL design model is ready" + : HasDiscoveryCache ? "Saved IED model is ready" : "IED is ready"; + public string ReadyWorkspaceMessage => HasSclDesignModel + ? RequiresEndpointBinding + ? "The LD/LN/DO/DA model is available offline. Press Play to bind an MMS endpoint before connecting." + : SelectedLiveSignalCount > 0 + ? $"{SelectedLiveSignalCount:N0} SCL signal(s) are selected. Play performs a fast MMS association; Re-scan compares the complete live model." + : "Browse and choose signals offline. Play associates without repeating full discovery; Re-scan performs design-versus-live verification." + : HasDiscoveryCache && SelectedLiveSignalCount > 0 + ? $"{SelectedLiveSignalCount:N0} saved live signal(s) are selected. Use Play or Connect All for immediate live values without a full discovery scan." + : HasDiscoveryCache + ? "The complete signal model is restored. Choose signals offline; Apply & Start Live connects and starts monitoring automatically." + : "Choose signals in the wizard; Apply & Start Live begins monitoring automatically."; public string ConnectionActionLabel => IsBusy ? "Working…" : IsConnected ? "Disconnect" : "Connect"; public string MonitorActionLabel => IsBusy ? "Working…" : IsMonitoring ? "Stop Monitor" : "Start Monitor"; public string ActivityText => IsBusy ? "Working…" : IsMonitoring ? "Monitoring" : Status; public string SummaryText => $"{SignalCount} scanned • {SelectedSignalCount} selected • {PointCount} live"; public string IdentityText => string.IsNullOrWhiteSpace(LogicalDeviceSummary) ? EndpointText - : $"{EndpointText} • LD {LogicalDeviceSummary}"; + : HasSclDesignModel + ? $"{EndpointText} • {LogicalDeviceSummary}" + : $"{EndpointText} • LD {LogicalDeviceSummary}"; public string ConnectionGlyph => IsBusy ? "…" : IsConnected ? "⏻" : "↗"; public string ConnectionToolTip => IsBusy ? "IED connection operation is running" : IsConnected ? $"Disconnect {Name}" - : $"Connect and discover {EndpointText}"; + : HasSclDesignModel + ? $"Connect {Name} using the SCL design model" + : $"Connect and discover {EndpointText}"; public string MonitorGlyph => IsBusy ? "…" : IsMonitoring ? "■" : "▶"; public string MonitorToolTip => IsBusy ? "IED session operation is running" @@ -354,9 +433,13 @@ public void MarkDiscoveryFailed(string message) ? $"Stop monitoring {Name} before changing its signal selection" : $"Open signal selection wizard for {Name}"; public string PlayToolTip => !IsConnected - ? HasDiscoveryCache - ? $"Fast-connect {Name} from the saved model and start its selected live values" - : $"Connect and discover {EndpointText}" + ? RequiresEndpointBinding + ? $"Bind an MMS endpoint for {Name}, then fast-connect from the SCL design model" + : HasSclDesignModel + ? $"Fast-connect {Name} from the SCL design model; use Re-scan for full live comparison" + : HasDiscoveryCache + ? $"Fast-connect {Name} from the saved model and start its selected live values" + : $"Connect and discover {EndpointText}" : IsMonitoring ? $"{Name} is already monitoring" : SelectedLiveSignalCount == 0 @@ -436,6 +519,10 @@ public void RefreshComputed() Raise(nameof(CanStopAction)); Raise(nameof(CanRescan)); Raise(nameof(CacheStateText)); + Raise(nameof(HasSclDesignModel)); + Raise(nameof(RequiresEndpointBinding)); + Raise(nameof(HasSclConfigurationDrift)); + Raise(nameof(SclVerificationText)); Raise(nameof(ReadyWorkspaceTitle)); Raise(nameof(ReadyWorkspaceMessage)); Raise(nameof(ConnectionActionLabel)); @@ -681,7 +768,7 @@ private static bool TryParseBinaryState(string? value, out bool state) public sealed class Iec61850TesterProject { - public int SchemaVersion { get; set; } = 2; + public int SchemaVersion { get; set; } = 3; public string ProjectName { get; set; } = "ArIED 61850 Session"; public int DefaultPollingIntervalMs { get; set; } = 1000; public List Devices { get; set; } = new(); @@ -697,6 +784,10 @@ public sealed class Iec61850TesterDeviceProfile public int Port { get; set; } = 102; public bool AllowDynamicDataSetWrites { get; set; } = true; public bool DiscoverySucceeded { get; set; } + public string SclSourcePath { get; set; } = string.Empty; + public string SclSourceSha256 { get; set; } = string.Empty; + public string SclIedName { get; set; } = string.Empty; + public string SclAccessPointName { get; set; } = string.Empty; public List SelectedReferences { get; set; } = new(); public List CachedSignals { get; set; } = new(); } diff --git a/Services/SclImportService.cs b/Services/SclImportService.cs deleted file mode 100644 index 77bcc13c..00000000 --- a/Services/SclImportService.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Net; -using System.Xml; -using System.Xml.Linq; - -namespace ArIED61850Tester.Services; - -public sealed record SclIedEndpoint( - string IedName, - string AccessPointName, - string IpAddress, - int Port, - string SubNetworkName) -{ - public string EndpointText => $"{IpAddress}:{Port}"; -} - -public sealed class SclImportResult -{ - public string SourceFilePath { get; init; } = string.Empty; - public int IedDefinitionCount { get; init; } - public int ConnectedAccessPointCount { get; init; } - public IReadOnlyList Endpoints { get; init; } = Array.Empty(); - public IReadOnlyList Warnings { get; init; } = Array.Empty(); -} - -/// -/// Reads IEC 61850 SCL communication data without trusting external entities. -/// The importer is namespace-version agnostic and extracts IED/AP/IP endpoint -/// identity only; the live IED model is still verified through MMS discovery. -/// -public static class SclImportService -{ - private static readonly string[] IpParameterNames = - { - "IP", "IPv4", "IPv6", "IP-Address", "IPAddress" - }; - - private static readonly string[] PortParameterNames = - { - "MMS-Port", "MMS_PORT", "IP-Port", "TCP-Port", "Port" - }; - - public static Task LoadAsync(string filePath, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); - return Task.Run(() => Load(filePath, cancellationToken), cancellationToken); - } - - private static SclImportResult Load(string filePath, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!File.Exists(filePath)) - throw new FileNotFoundException("The selected SCL file no longer exists.", filePath); - - var settings = new XmlReaderSettings - { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null, - IgnoreComments = true, - IgnoreProcessingInstructions = true, - IgnoreWhitespace = true, - CloseInput = true - }; - - XDocument document; - using (var stream = File.OpenRead(filePath)) - using (var reader = XmlReader.Create(stream, settings)) - document = XDocument.Load(reader, LoadOptions.None); - - cancellationToken.ThrowIfCancellationRequested(); - var root = document.Root ?? throw new InvalidDataException("The selected XML document is empty."); - if (!NameIs(root, "SCL")) - throw new InvalidDataException("The selected XML document is not an IEC 61850 SCL file (missing SCL root element)."); - - var warnings = new List(); - var iedNames = root - .Descendants() - .Where(element => NameIs(element, "IED")) - .Select(element => AttributeValue(element, "name")) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - var connectedAccessPoints = root - .Descendants() - .Where(element => NameIs(element, "ConnectedAP")) - .ToArray(); - - var endpoints = new List(); - var endpointKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var connectedAp in connectedAccessPoints) - { - cancellationToken.ThrowIfCancellationRequested(); - - var iedName = AttributeValue(connectedAp, "iedName"); - var accessPointName = AttributeValue(connectedAp, "apName"); - var subNetworkName = connectedAp - .Ancestors() - .FirstOrDefault(element => NameIs(element, "SubNetwork")) is { } subNetwork - ? AttributeValue(subNetwork, "name") - : string.Empty; - - if (string.IsNullOrWhiteSpace(iedName)) - { - warnings.Add("A ConnectedAP entry was ignored because its iedName attribute is empty."); - continue; - } - - if (iedNames.Count > 0 && !iedNames.Contains(iedName)) - warnings.Add($"ConnectedAP '{iedName}/{accessPointName}' does not match an IED definition in the file; its endpoint was still imported."); - - var parameters = ReadAddressParameters(connectedAp).ToArray(); - var ipText = FindParameter(parameters, IpParameterNames); - if (string.IsNullOrWhiteSpace(ipText)) - { - warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has no IP address in its ConnectedAP Address block."); - continue; - } - - if (!IPAddress.TryParse(ipText, out var parsedIp)) - { - warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has an invalid IP address '{ipText}'."); - continue; - } - - var port = 102; - var portText = FindParameter(parameters, PortParameterNames); - if (!string.IsNullOrWhiteSpace(portText) && - (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out port) || port is <= 0 or > 65535)) - { - warnings.Add($"{iedName}/{DisplayAccessPoint(accessPointName)} has invalid MMS port '{portText}'; TCP 102 was used."); - port = 102; - } - - var canonicalIp = parsedIp.ToString(); - var key = $"{canonicalIp}|{port}"; - if (!endpointKeys.Add(key)) - { - warnings.Add($"Duplicate endpoint {canonicalIp}:{port} for {iedName}/{DisplayAccessPoint(accessPointName)} was ignored."); - continue; - } - - endpoints.Add(new SclIedEndpoint( - iedName, - accessPointName, - canonicalIp, - port, - subNetworkName)); - } - - return new SclImportResult - { - SourceFilePath = Path.GetFullPath(filePath), - IedDefinitionCount = iedNames.Count, - ConnectedAccessPointCount = connectedAccessPoints.Length, - Endpoints = endpoints - .OrderBy(endpoint => endpoint.IedName, StringComparer.OrdinalIgnoreCase) - .ThenBy(endpoint => endpoint.AccessPointName, StringComparer.OrdinalIgnoreCase) - .ThenBy(endpoint => endpoint.IpAddress, StringComparer.OrdinalIgnoreCase) - .ToArray(), - Warnings = warnings.ToArray() - }; - } - - private static IEnumerable> ReadAddressParameters(XElement connectedAp) - { - var address = connectedAp.Elements().FirstOrDefault(element => NameIs(element, "Address")) ?? - connectedAp.Descendants().FirstOrDefault(element => NameIs(element, "Address")); - if (address == null) - yield break; - - foreach (var parameter in address.Elements().Where(element => NameIs(element, "P"))) - { - var type = AttributeValue(parameter, "type"); - var value = parameter.Value.Trim(); - if (!string.IsNullOrWhiteSpace(type) && !string.IsNullOrWhiteSpace(value)) - yield return new KeyValuePair(type, value); - } - } - - private static string FindParameter( - IReadOnlyCollection> parameters, - IEnumerable candidateNames) - { - foreach (var candidate in candidateNames) - { - var value = parameters.FirstOrDefault(parameter => - parameter.Key.Equals(candidate, StringComparison.OrdinalIgnoreCase)).Value; - if (!string.IsNullOrWhiteSpace(value)) - return value.Trim(); - } - - return string.Empty; - } - - private static bool NameIs(XElement element, string localName) - => element.Name.LocalName.Equals(localName, StringComparison.OrdinalIgnoreCase); - - private static string AttributeValue(XElement element, string localName) - => element.Attributes() - .FirstOrDefault(attribute => attribute.Name.LocalName.Equals(localName, StringComparison.OrdinalIgnoreCase)) - ?.Value.Trim() ?? string.Empty; - - private static string DisplayAccessPoint(string accessPointName) - => string.IsNullOrWhiteSpace(accessPointName) ? "unnamed AP" : accessPointName; -} diff --git a/Services/SclLiveSignalModelProjection.cs b/Services/SclLiveSignalModelProjection.cs new file mode 100644 index 00000000..802ec10b --- /dev/null +++ b/Services/SclLiveSignalModelProjection.cs @@ -0,0 +1,276 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Builds a bounded engine discovery document from ArIED signal rows so the +/// ARIEC61850 SCL comparer remains the only owner of design-versus-live rules. +/// The projection is intentionally limited to model elements exposed by the +/// application discovery workflow: DA references, FC/type evidence, DataSets, +/// and ReportControl bindings. +/// +public static class SclLiveSignalModelProjection +{ + public static LiveIedModelDiscoveryDocument Build( + string iedName, + string accessPointName, + IEnumerable signals) + { + var rows = (signals ?? Array.Empty()) + .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) + .ToArray(); + + var logicalDevices = rows + .Select(ToDescriptor) + .Where(descriptor => descriptor != null) + .Cast() + .GroupBy(descriptor => descriptor.Domain, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(domain => new LiveIedLogicalDeviceModel + { + MmsDomain = domain.Key, + Inst = LogicalDeviceInst(domain.Key, iedName), + LogicalNodes = domain + .GroupBy(descriptor => descriptor.LogicalNode, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(logicalNode => BuildLogicalNode(logicalNode.Key, logicalNode)) + .ToArray() + }) + .ToArray(); + + var dataSets = BuildDataSets(rows); + var reports = BuildReportControls(rows, dataSets); + var coverage = BuildCoverage(logicalDevices, dataSets, reports); + + return new LiveIedModelDiscoveryDocument + { + Source = "ArIEDSignalProjection", + IedName = iedName, + AccessPointName = accessPointName, + LogicalDevices = logicalDevices, + DataSets = dataSets, + ReportControls = reports, + Coverage = coverage, + Summary = $"ArIED runtime projection: LD={coverage.LogicalDeviceCount}, LN={coverage.LogicalNodeCount}, DO={coverage.DataObjectCount}, DA={coverage.DataAttributeCount}, RCB={coverage.ReportControlCount}, DataSet={coverage.DataSetCount}." + }; + } + + private static LiveIedLogicalNodeModel BuildLogicalNode( + string logicalNodeName, + IEnumerable descriptors) + { + var descriptorArray = descriptors.ToArray(); + var parts = SignalDefinition.DetectLogicalNodeClass(logicalNodeName); + var dataObjects = descriptorArray + .GroupBy(descriptor => descriptor.DataObject, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => BuildDataObject(group.Key, group)) + .ToArray(); + + return new LiveIedLogicalNodeModel + { + Name = logicalNodeName, + LnClass = parts, + ProposedLnTypeId = $"ARIED_{SafeId(parts)}_{SafeId(logicalNodeName)}", + FunctionalConstraintCounts = dataObjects + .SelectMany(dataObject => dataObject.Attributes) + .Where(attribute => !string.IsNullOrWhiteSpace(attribute.FunctionalConstraint)) + .GroupBy(attribute => attribute.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase), + DataObjects = dataObjects + }; + } + + private static LiveIedDataObjectModel BuildDataObject( + string dataObjectName, + IEnumerable descriptors) + { + var descriptorArray = descriptors.ToArray(); + var primary = descriptorArray.First(); + var attributes = descriptorArray + .Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.AttributePath)) + .GroupBy(descriptor => NormalizeReference(descriptor.Reference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(descriptor => descriptor.AttributePath, StringComparer.OrdinalIgnoreCase) + .Select(descriptor => new LiveIedDataAttributeModel + { + ObjectReference = descriptor.Reference, + AttributePath = descriptor.AttributePath, + FunctionalConstraint = descriptor.FunctionalConstraint, + MmsReference = descriptor.Reference, + MmsItemName = descriptor.Reference.Contains('/') + ? descriptor.Reference[(descriptor.Reference.IndexOf('/') + 1)..] + : descriptor.Reference, + Source = "ArIED signal model", + SclBType = descriptor.DataType, + MmsType = descriptor.DataType, + MmsTypeSignature = descriptor.DataType, + TypeDiscoveryStatus = "Projected", + TypeDiscoveryMessage = "Projected from the ArIED discovery signal row.", + TypeSource = "ArIED discovery", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.High, + FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(descriptor.FunctionalConstraint) + ? LiveIedDiscoveryConfidenceLevel.Unknown + : LiveIedDiscoveryConfidenceLevel.Exact + }) + .ToArray(); + + return new LiveIedDataObjectModel + { + Reference = primary.ObjectReference, + Name = dataObjectName, + ProposedDoTypeId = $"ARIED_DO_{SafeId(primary.Cdc)}_{SafeId(dataObjectName)}", + InferredCdc = primary.Cdc, + CdcConfidence = string.IsNullOrWhiteSpace(primary.Cdc) ? 0.5 : 0.9, + ConfidenceLevel = string.IsNullOrWhiteSpace(primary.Cdc) + ? LiveIedDiscoveryConfidenceLevel.Medium + : LiveIedDiscoveryConfidenceLevel.High, + Evidence = new[] { "Projected from ArIED live discovery signal metadata." }, + Attributes = attributes + }; + } + + private static IReadOnlyList BuildDataSets(IReadOnlyList signals) + => signals + .Where(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) + .GroupBy(signal => signal.DataSetReference.Trim(), StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var reference = group.Key; + var domain = Domain(reference); + var tail = ReferenceTail(reference); + var separator = tail.LastIndexOf('.'); + var logicalNode = separator > 0 ? tail[..separator] : string.Empty; + var name = separator > 0 ? tail[(separator + 1)..] : tail; + var members = group + .Where(signal => !signal.IsControlSignal) + .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Select(values => values.First()) + .Select((signal, index) => new LiveIedDataSetMemberModel + { + Index = index + 1, + Reference = signal.ObjectReference, + FunctionalConstraint = signal.FunctionalConstraint, + MmsReference = signal.ObjectReference, + Confidence = LiveIedDiscoveryConfidenceLevel.High + }) + .ToArray(); + + return new LiveIedDataSetModel + { + Reference = reference, + Domain = domain, + LogicalNode = logicalNode, + Name = name, + MemberCount = members.Length, + Members = members + }; + }) + .ToArray(); + + private static IReadOnlyList BuildReportControls( + IReadOnlyList signals, + IReadOnlyList dataSets) + { + var dataSetIndex = dataSets.ToDictionary( + dataSet => NormalizeReference(dataSet.Reference), + dataSet => dataSet, + StringComparer.OrdinalIgnoreCase); + + return signals + .Where(signal => !string.IsNullOrWhiteSpace(signal.ReportControlReference)) + .GroupBy(signal => signal.ReportControlReference.Trim(), StringComparer.OrdinalIgnoreCase) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var first = group.First(); + var reference = group.Key; + var tail = ReferenceTail(reference); + var name = tail.Contains('.') ? tail[(tail.LastIndexOf('.') + 1)..] : tail; + dataSetIndex.TryGetValue(NormalizeReference(first.DataSetReference), out var dataSet); + return new LiveIedReportControlModel + { + Reference = reference, + Domain = Domain(reference), + LogicalNode = tail.Contains('.') ? tail[..tail.IndexOf('.')] : "LLN0", + Name = name, + Buffered = NormalizeReference(reference).Contains(".BR.", StringComparison.OrdinalIgnoreCase), + DataSetReference = dataSet?.Reference ?? first.DataSetReference, + Status = "Projected from ArIED discovery" + }; + }) + .ToArray(); + } + + private static LiveIedModelDiscoveryCoverage BuildCoverage( + IReadOnlyList logicalDevices, + IReadOnlyList dataSets, + IReadOnlyList reports) + { + var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray(); + var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray(); + var attributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray(); + return new LiveIedModelDiscoveryCoverage + { + LogicalDeviceCount = logicalDevices.Count, + LogicalNodeCount = logicalNodes.Length, + DataObjectCount = dataObjects.Length, + DataAttributeCount = attributes.Length, + ExactFunctionalConstraintCount = attributes.Count(attribute => attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact), + ExactMmsTypeCount = attributes.Count(attribute => !string.IsNullOrWhiteSpace(attribute.MmsType)), + HighConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High), + MediumConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel == LiveIedDiscoveryConfidenceLevel.Medium), + DataSetCount = dataSets.Count, + ReportControlCount = reports.Count, + BufferedReportControlCount = reports.Count(report => report.Buffered), + UnbufferedReportControlCount = reports.Count(report => !report.Buffered) + }; + } + + private static SignalDescriptor? ToDescriptor(SignalDefinition signal) + { + var reference = signal.ObjectReference.Replace('$', '.').Trim(); + var slash = reference.IndexOf('/'); + if (slash <= 0 || slash >= reference.Length - 1) + return null; + + var domain = reference[..slash]; + var member = reference[(slash + 1)..]; + var segments = member.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length < 2) + return null; + + var logicalNode = segments[0]; + var dataObject = segments[1]; + var attributePath = segments.Length > 2 ? string.Join('.', segments.Skip(2)) : string.Empty; + if (signal.IsControlSignal && string.IsNullOrWhiteSpace(attributePath)) + attributePath = "Oper.ctlVal"; + + return new SignalDescriptor( + domain, + logicalNode, + dataObject, + attributePath, + reference, + $"{domain}/{logicalNode}.{dataObject}", + signal.FunctionalConstraint, + signal.DataType, + signal.ControlCdc); + } + + private static string Domain(string reference) + { + var slash = reference.IndexOf('/'); + return slash > 0 ? reference[..slash] : string.Empty; + } + + private static string ReferenceTail(string reference) + { + var text = reference.Replace('$', '.'); + var slash = text.IndexOf('/'); + return slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text; + } + + private static string LogicalDeviceInst(string domain, string iedName) diff --git a/Services/SclWorkspaceSignalMapper.cs b/Services/SclWorkspaceSignalMapper.cs new file mode 100644 index 00000000..3f27b095 --- /dev/null +++ b/Services/SclWorkspaceSignalMapper.cs @@ -0,0 +1,261 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Presentation adapter from the engine-owned SCL workspace model to ArIED signal rows. +/// This class deliberately contains no XML parsing or IEC 61850 type-template traversal. +/// +public static class SclWorkspaceSignalMapper +{ + private static readonly HashSet RuntimeLeafNames = new(StringComparer.OrdinalIgnoreCase) + { + "stVal", "general", "posVal", "actVal", "setVal", "f", "i" + }; + + public static IReadOnlyList BuildSignals(SclIedWorkspace workspace) + { + ArgumentNullException.ThrowIfNull(workspace); + + var dataSetBindings = BuildDataSetBindings(workspace.DesignModel); + var reportBindings = BuildReportBindings(workspace.DesignModel); + var signals = new List(); + + foreach (var logicalDevice in workspace.DesignModel.LogicalDevices) + { + foreach (var logicalNode in logicalDevice.LogicalNodes) + { + foreach (var dataObject in logicalNode.DataObjects) + { + AddRuntimeSignals(signals, logicalNode, dataObject, dataSetBindings, reportBindings); + AddControlSignal(signals, logicalNode, dataObject, dataSetBindings, reportBindings); + } + } + } + + return signals + .Where(signal => signal.CanPublishAsSignal || signal.IsControlSignal) + .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(signal => signal.SortPriority) + .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) + .ThenBy(signal => signal.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static void AddRuntimeSignals( + ICollection signals, + LiveIedLogicalNodeModel logicalNode, + LiveIedDataObjectModel dataObject, + IReadOnlyDictionary dataSetBindings, + IReadOnlyDictionary reportBindings) + { + var quality = FindCompanion(dataObject, "q"); + var timestamp = FindCompanion(dataObject, "t"); + + foreach (var attribute in dataObject.Attributes) + { + var fc = (attribute.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); + if (fc is not ("ST" or "MX") || IsCompanion(attribute.AttributePath)) + continue; + if (!IsRuntimeLeaf(attribute.AttributePath)) + continue; + + var reference = attribute.ObjectReference; + var normalized = NormalizeReference(reference); + dataSetBindings.TryGetValue(normalized, out var dataSetReference); + reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); + var category = ResolveCategory(logicalNode.LnClass, dataObject.Name, dataObject.InferredCdc, fc); + + signals.Add(new SignalDefinition + { + Name = BuildDisplayName(logicalNode.Name, dataObject.Name, attribute.AttributePath), + ObjectReference = reference, + DisplayReference = reference, + FunctionalConstraint = fc, + DataType = ResolveDataType(attribute), + Category = category, + Confidence = attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High + ? "High" + : "Medium", + DataSetReference = dataSetReference ?? string.Empty, + ReportControlReference = reportReference ?? string.Empty, + ReportCoverageReason = string.IsNullOrWhiteSpace(reportReference) + ? "SCL design model contains no static ReportControl coverage; polling remains the safe fallback." + : $"Static SCL report candidate {reportReference}; live RCB attributes are verified before enable.", + QualityReference = quality?.ObjectReference ?? string.Empty, + TimestampReference = timestamp?.ObjectReference ?? string.Empty, + Source = "SCL design model", + IsReportCapable = !string.IsNullOrWhiteSpace(reportReference), + ReportCoverage = string.IsNullOrWhiteSpace(reportReference) + ? "MMS polling fallback" + : "Static SCL report candidate", + IsSelected = false, + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-", + ProbeStatus = "Projected from SCL; live read verification pending" + }); + } + } + + private static void AddControlSignal( + ICollection signals, + LiveIedLogicalNodeModel logicalNode, + LiveIedDataObjectModel dataObject, + IReadOnlyDictionary dataSetBindings, + IReadOnlyDictionary reportBindings) + { + var controlAttributes = dataObject.Attributes + .Where(attribute => string.Equals(attribute.FunctionalConstraint, "CO", StringComparison.OrdinalIgnoreCase) || + Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (controlAttributes.Length == 0) + return; + + var controlReference = dataObject.Reference; + var ctlModel = dataObject.Attributes.FirstOrDefault(attribute => + Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)); + var status = dataObject.Attributes.FirstOrDefault(attribute => + Leaf(attribute.AttributePath).Equals("stVal", StringComparison.OrdinalIgnoreCase)); + var normalizedStatus = NormalizeReference(status?.ObjectReference); + dataSetBindings.TryGetValue(normalizedStatus, out var dataSetReference); + reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); + + signals.Add(new SignalDefinition + { + Name = $"{logicalNode.Name} {dataObject.Name}", + ObjectReference = controlReference, + DisplayReference = controlReference, + FunctionalConstraint = "CO", + DataType = string.IsNullOrWhiteSpace(dataObject.InferredCdc) + ? "IEC 61850 control" + : $"{dataObject.InferredCdc} control", + Category = "Control", + Confidence = "High", + DataSetReference = dataSetReference ?? string.Empty, + ReportControlReference = reportReference ?? string.Empty, + ReportCoverageReason = "Control execution is disabled until the live ctlModel and exact MMS Oper/SBOw/Cancel structures are inspected.", + Source = "SCL design model", + IsControlSignal = true, + ControlCdc = dataObject.InferredCdc, + ControlModelReference = ctlModel?.ObjectReference ?? $"{controlReference}.ctlModel", + ControlStatusReference = status?.ObjectReference ?? string.Empty, + ControlModelText = "SCL design • live verification required", + ControlValueType = ResolveControlValueType(dataObject.InferredCdc), + IsSelected = false, + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-", + ProbeStatus = "SCL control candidate; live verification required" + }); + } + + private static Dictionary BuildDataSetBindings(LiveIedModelDiscoveryDocument model) + { + var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var dataSet in model.DataSets) + { + foreach (var member in dataSet.Members) + { + var key = NormalizeReference(member.Reference); + if (!string.IsNullOrWhiteSpace(key)) + bindings.TryAdd(key, dataSet.Reference); + } + } + return bindings; + } + + private static Dictionary BuildReportBindings(LiveIedModelDiscoveryDocument model) + { + var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var report in model.ReportControls) + { + var key = NormalizeReference(report.DataSetReference); + if (!string.IsNullOrWhiteSpace(key)) + bindings.TryAdd(key, report.Reference); + } + return bindings; + } + + private static LiveIedDataAttributeModel? FindCompanion(LiveIedDataObjectModel dataObject, string leaf) + => dataObject.Attributes.FirstOrDefault(attribute => Leaf(attribute.AttributePath).Equals(leaf, StringComparison.OrdinalIgnoreCase)); + + private static bool IsCompanion(string path) + { + var leaf = Leaf(path); + return leaf.Equals("q", StringComparison.OrdinalIgnoreCase) || + leaf.Equals("t", StringComparison.OrdinalIgnoreCase) || + leaf.Equals("ctlModel", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsRuntimeLeaf(string path) + { + var leaf = Leaf(path); + if (RuntimeLeafNames.Contains(leaf)) + return true; + + var normalized = path.Replace('$', '.'); + return normalized.EndsWith("mag.f", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith("ang.f", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith("instMag.i", StringComparison.OrdinalIgnoreCase); + } + + private static string ResolveDataType(LiveIedDataAttributeModel attribute) + { + if (!string.IsNullOrWhiteSpace(attribute.SclBType)) + return attribute.SclBType; + if (!string.IsNullOrWhiteSpace(attribute.MmsType)) + return attribute.MmsType; + return "IEC 61850 value"; + } + + private static string ResolveCategory(string lnClass, string doName, string cdc, string fc) + { + if (fc.Equals("MX", StringComparison.OrdinalIgnoreCase)) + return "Measurement"; + if (doName.Equals("Pos", StringComparison.OrdinalIgnoreCase) || cdc.Equals("DPC", StringComparison.OrdinalIgnoreCase)) + return "Position"; + if (lnClass.StartsWith('P') || doName.Equals("Op", StringComparison.OrdinalIgnoreCase) || doName.Equals("Str", StringComparison.OrdinalIgnoreCase)) + return "Protection"; + return "Status"; + } + + private static string ResolveControlValueType(string cdc) + => (cdc ?? string.Empty).Trim().ToUpperInvariant() switch + { + "DPC" => "Dbpos", + "SPC" => "Boolean", + "INC" or "ISC" or "BSC" => "Int32", + "APC" or "BAC" => "Float32", + _ => string.Empty + }; + + private static string BuildDisplayName(string logicalNode, string dataObject, string attributePath) + => $"{logicalNode} {dataObject} {attributePath}"; + + private static string Leaf(string? path) + { + var text = (path ?? string.Empty).Replace('$', '.').Trim('.'); + var index = text.LastIndexOf('.'); + return index >= 0 ? text[(index + 1)..] : text; + } + + private static string NormalizeReference(string? reference) + { + var text = (reference ?? string.Empty).Trim(); + var fcMarker = text.LastIndexOf(" [", StringComparison.Ordinal); + if (fcMarker >= 0) + text = text[..fcMarker]; + text = text.Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim(); + + // SCL FCDA display references commonly use IED/LD/LN while MMS domains use IEDLD/LN. + var parts = text.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length >= 3) + text = string.Concat(parts[0], parts[1], "/", string.Join("/", parts.Skip(2))); + + return text.ToUpperInvariant(); + } +} diff --git a/.integration/SCL_WORKSPACE_INTEGRATION.md b/docs/SCL_WORKSPACE_INTEGRATION.md similarity index 100% rename from .integration/SCL_WORKSPACE_INTEGRATION.md rename to docs/SCL_WORKSPACE_INTEGRATION.md From 4aa89c895b03a01cf1d802ff663e7f6a81277323 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:16:49 +0700 Subject: [PATCH 15/21] ci: validate SCL workspace integration against engine branch --- .github/workflows/build.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 63dd24d4..a57edd07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +env: + # Temporary integration pin. Switch this to main after ARIEC61850 PR #25 is merged. + ARIEC61850_REF: agent/scl-workspace-api + jobs: build-windows: runs-on: windows-latest @@ -36,21 +40,29 @@ jobs: - name: Checkout ARIEC61850 engine shell: powershell - run: git clone --quiet --depth 1 https://github.com/masarray/ARIEC61850.git ARIEC61850 + run: git clone --quiet --depth 1 --branch $env:ARIEC61850_REF https://github.com/masarray/ARIEC61850.git ARIEC61850 - - name: Verify ARIEC61850 Smart Control API + - name: Verify required ARIEC61850 APIs shell: powershell run: | $control = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlService.cs" $models = ".\ARIEC61850\src\AR.Iec61850\Control\Iec61850ControlModels.cs" + $workspace = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceService.cs" + $workspaceModels = ".\ARIEC61850\src\AR.Iec61850\Scl\Workspace\SclWorkspaceModels.cs" if (!(Test-Path $control) -or !(Test-Path $models)) { - throw "ARIEC61850 Smart Control source was not found. ArIED 1.6.5 requires the native Control namespace." + throw "ARIEC61850 Smart Control source was not found. ArIED requires the native Control namespace." } if (!(Select-String -Path $models -Pattern "interface IIec61850ControlService" -Quiet)) { throw "ARIEC61850 does not expose IIec61850ControlService." } if (!(Select-String -Path $models -Pattern "CommandTerminationReceived" -Quiet)) { - throw "ARIEC61850 Smart Control result contract is too old for ArIED 1.6.5." + throw "ARIEC61850 Smart Control result contract is too old for ArIED." + } + if (!(Test-Path $workspace) -or !(Test-Path $workspaceModels)) { + throw "ARIEC61850 SCL Workspace API was not found. ArIED Open SCL must use the engine-owned workspace service." + } + if (!(Select-String -Path $workspace -Pattern "CompareLive" -Quiet)) { + throw "ARIEC61850 SCL Workspace API does not expose design-versus-live comparison." } - name: Setup .NET 8 @@ -66,7 +78,7 @@ jobs: - name: Publish portable x64 shell: powershell - run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.5 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" + run: .\ArIED61850Tester\scripts\publish-windows-portable.ps1 -Version 1.6.6 -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" - name: Upload portable package uses: actions/upload-artifact@v4 From b6357c1b1f246f121a96cdb7f03bcb633c4dab76 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:17:06 +0700 Subject: [PATCH 16/21] ci: remove temporary integration apply workflow --- .github/workflows/apply-scl-integration.yml | 53 --------------------- 1 file changed, 53 deletions(-) delete mode 100644 .github/workflows/apply-scl-integration.yml diff --git a/.github/workflows/apply-scl-integration.yml b/.github/workflows/apply-scl-integration.yml deleted file mode 100644 index 927b7b4c..00000000 --- a/.github/workflows/apply-scl-integration.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Apply staged SCL integration - -on: - pull_request: - branches: [ main ] - workflow_dispatch: - -permissions: - contents: write - -jobs: - apply: - if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'agent/use-engine-scl-workspace') }} - runs-on: ubuntu-latest - steps: - - name: Checkout integration branch - uses: actions/checkout@v4 - with: - ref: agent/use-engine-scl-workspace - fetch-depth: 0 - - - name: Apply staged source changes - shell: bash - run: | - set -euo pipefail - for patch in \ - .integration/mainwindow-1.patch \ - .integration/mainwindow-2.patch \ - .integration/mainwindow-3.patch \ - .integration/mainwindow-4.patch \ - .integration/monitormodels-1.patch \ - .integration/monitormodels-2.patch \ - .integration/mapper.patch \ - .integration/projection.patch - do - git apply --check --ignore-space-change --ignore-whitespace "$patch" - git apply --ignore-space-change --ignore-whitespace "$patch" - done - - mkdir -p docs - cp .integration/SCL_WORKSPACE_INTEGRATION.md docs/SCL_WORKSPACE_INTEGRATION.md - rm Services/SclImportService.cs - rm -rf .integration - - - name: Commit final integration source - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Integrate engine-owned SCL workspace [applied]" - git push origin HEAD:agent/use-engine-scl-workspace From 99590b3789cc75fba61f54a55b46ba91aee6c8e7 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:19:06 +0700 Subject: [PATCH 17/21] ci: add focused SCL integration compile gate --- .github/workflows/compile-scl-integration.yml | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/compile-scl-integration.yml diff --git a/.github/workflows/compile-scl-integration.yml b/.github/workflows/compile-scl-integration.yml new file mode 100644 index 00000000..e801e35f --- /dev/null +++ b/.github/workflows/compile-scl-integration.yml @@ -0,0 +1,27 @@ +name: Compile SCL integration + +on: + pull_request: + branches: [ main ] + +jobs: + compile: + if: ${{ github.event.pull_request.head.ref == 'agent/use-engine-scl-workspace' }} + runs-on: windows-latest + steps: + - name: Checkout ArIED branch + shell: powershell + run: git clone --quiet --depth 1 --branch agent/use-engine-scl-workspace https://github.com/masarray/ArIED61850Tester.git ArIED61850Tester + + - name: Checkout engine workspace branch + shell: powershell + run: git clone --quiet --depth 1 --branch agent/scl-workspace-api https://github.com/masarray/ARIEC61850.git ARIEC61850 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Compile + shell: powershell + run: dotnet build .\ArIED61850Tester\ArIED61850Tester.csproj -c Release -v:q --nologo From 46bc699e2f249370f1b50689ad4a28db51b5b9fc Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:21:21 +0700 Subject: [PATCH 18/21] fix: complete SCL live model projection source --- Services/SclLiveSignalModelProjection.cs | 359 ++++++++++++----------- 1 file changed, 182 insertions(+), 177 deletions(-) diff --git a/Services/SclLiveSignalModelProjection.cs b/Services/SclLiveSignalModelProjection.cs index 802ec10b..a55622bc 100644 --- a/Services/SclLiveSignalModelProjection.cs +++ b/Services/SclLiveSignalModelProjection.cs @@ -4,75 +4,122 @@ namespace ArIED61850Tester.Services; /// -/// Builds a bounded engine discovery document from ArIED signal rows so the -/// ARIEC61850 SCL comparer remains the only owner of design-versus-live rules. -/// The projection is intentionally limited to model elements exposed by the -/// application discovery workflow: DA references, FC/type evidence, DataSets, -/// and ReportControl bindings. +/// Converts the application-neutral signal rows produced by ARIEC61850 discovery into +/// a bounded live-model projection that can be evaluated by the engine SCL comparer. +/// It does not parse SCL or infer protocol services from XML. /// public static class SclLiveSignalModelProjection { public static LiveIedModelDiscoveryDocument Build( string iedName, string accessPointName, - IEnumerable signals) + IReadOnlyList signals) { - var rows = (signals ?? Array.Empty()) - .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) + ArgumentNullException.ThrowIfNull(signals); + + var attributes = signals + .SelectMany(ToAttributeRows) + .GroupBy(row => row.Reference, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) .ToArray(); - var logicalDevices = rows - .Select(ToDescriptor) - .Where(descriptor => descriptor != null) - .Cast() - .GroupBy(descriptor => descriptor.Domain, StringComparer.OrdinalIgnoreCase) + var logicalDevices = attributes + .GroupBy(row => row.Domain, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .Select(domain => new LiveIedLogicalDeviceModel + .Select(domainGroup => new LiveIedLogicalDeviceModel { - MmsDomain = domain.Key, - Inst = LogicalDeviceInst(domain.Key, iedName), - LogicalNodes = domain - .GroupBy(descriptor => descriptor.LogicalNode, StringComparer.OrdinalIgnoreCase) + MmsDomain = domainGroup.Key, + Inst = ResolveLdInst(domainGroup.Key, iedName), + LogicalNodes = domainGroup + .GroupBy(row => row.LogicalNode, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .Select(logicalNode => BuildLogicalNode(logicalNode.Key, logicalNode)) + .Select(logicalNodeGroup => BuildLogicalNode(logicalNodeGroup.Key, logicalNodeGroup)) .ToArray() }) .ToArray(); - var dataSets = BuildDataSets(rows); - var reports = BuildReportControls(rows, dataSets); - var coverage = BuildCoverage(logicalDevices, dataSets, reports); + var dataSets = BuildDataSets(signals); + var reports = BuildReportControls(signals); + var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray(); + var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray(); + var dataAttributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray(); return new LiveIedModelDiscoveryDocument { Source = "ArIEDSignalProjection", - IedName = iedName, - AccessPointName = accessPointName, + IedName = iedName ?? string.Empty, + AccessPointName = accessPointName ?? string.Empty, LogicalDevices = logicalDevices, DataSets = dataSets, ReportControls = reports, - Coverage = coverage, - Summary = $"ArIED runtime projection: LD={coverage.LogicalDeviceCount}, LN={coverage.LogicalNodeCount}, DO={coverage.DataObjectCount}, DA={coverage.DataAttributeCount}, RCB={coverage.ReportControlCount}, DataSet={coverage.DataSetCount}." + Coverage = new LiveIedModelDiscoveryCoverage + { + LogicalDeviceCount = logicalDevices.Length, + LogicalNodeCount = logicalNodes.Length, + DataObjectCount = dataObjects.Length, + DataAttributeCount = dataAttributes.Length, + ExactFunctionalConstraintCount = dataAttributes.Count(attribute => + attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact), + ExactMmsTypeCount = dataAttributes.Count(attribute => + attribute.TypeConfidence is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High), + DataSetCount = dataSets.Length, + ReportControlCount = reports.Length, + BufferedReportControlCount = reports.Count(report => report.Buffered), + UnbufferedReportControlCount = reports.Count(report => !report.Buffered) + }, + Summary = $"ArIED signal projection: LD={logicalDevices.Length}, LN={logicalNodes.Length}, DO={dataObjects.Length}, DA={dataAttributes.Length}, DataSet={dataSets.Length}, RCB={reports.Length}." }; } private static LiveIedLogicalNodeModel BuildLogicalNode( string logicalNodeName, - IEnumerable descriptors) + IEnumerable rows) { - var descriptorArray = descriptors.ToArray(); - var parts = SignalDefinition.DetectLogicalNodeClass(logicalNodeName); - var dataObjects = descriptorArray - .GroupBy(descriptor => descriptor.DataObject, StringComparer.OrdinalIgnoreCase) + var materialized = rows.ToArray(); + var lnClass = SignalDefinition.DetectLogicalNodeClass(logicalNodeName); + var dataObjects = materialized + .GroupBy(row => row.DataObject, StringComparer.OrdinalIgnoreCase) .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) - .Select(group => BuildDataObject(group.Key, group)) + .Select(group => new LiveIedDataObjectModel + { + Reference = $"{group.First().Domain}/{logicalNodeName}.{group.Key}", + Name = group.Key, + InferredCdc = group.Select(row => row.Cdc).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty, + CdcConfidence = 0.90, + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.High, + Evidence = new[] { "Projected from ARIEC61850 signal discovery output." }, + Attributes = group + .OrderBy(row => row.AttributePath, StringComparer.OrdinalIgnoreCase) + .Select(row => new LiveIedDataAttributeModel + { + ObjectReference = row.Reference, + AttributePath = row.AttributePath, + FunctionalConstraint = row.FunctionalConstraint, + MmsReference = row.Reference, + MmsItemName = row.Reference.Contains('/') + ? row.Reference[(row.Reference.IndexOf('/') + 1)..] + : row.Reference, + Source = "ArIED.SignalDefinition", + SclBType = row.DataType, + MmsType = row.DataType, + MmsTypeSignature = row.DataType, + TypeDiscoveryStatus = "Projected", + TypeDiscoveryMessage = "Projected from ARIEC61850 live signal discovery.", + TypeSource = "ARIEC61850 live discovery", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.High, + FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(row.FunctionalConstraint) + ? LiveIedDiscoveryConfidenceLevel.Unknown + : LiveIedDiscoveryConfidenceLevel.Exact + }) + .ToArray() + }) .ToArray(); return new LiveIedLogicalNodeModel { Name = logicalNodeName, - LnClass = parts, - ProposedLnTypeId = $"ARIED_{SafeId(parts)}_{SafeId(logicalNodeName)}", + LnClass = lnClass, + ProposedLnTypeId = $"LN_{lnClass}_{logicalNodeName}", FunctionalConstraintCounts = dataObjects .SelectMany(dataObject => dataObject.Attributes) .Where(attribute => !string.IsNullOrWhiteSpace(attribute.FunctionalConstraint)) @@ -82,85 +129,61 @@ private static LiveIedLogicalNodeModel BuildLogicalNode( }; } - private static LiveIedDataObjectModel BuildDataObject( - string dataObjectName, - IEnumerable descriptors) + private static IEnumerable ToAttributeRows(SignalDefinition signal) { - var descriptorArray = descriptors.ToArray(); - var primary = descriptorArray.First(); - var attributes = descriptorArray - .Where(descriptor => !string.IsNullOrWhiteSpace(descriptor.AttributePath)) - .GroupBy(descriptor => NormalizeReference(descriptor.Reference), StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(descriptor => descriptor.AttributePath, StringComparer.OrdinalIgnoreCase) - .Select(descriptor => new LiveIedDataAttributeModel - { - ObjectReference = descriptor.Reference, - AttributePath = descriptor.AttributePath, - FunctionalConstraint = descriptor.FunctionalConstraint, - MmsReference = descriptor.Reference, - MmsItemName = descriptor.Reference.Contains('/') - ? descriptor.Reference[(descriptor.Reference.IndexOf('/') + 1)..] - : descriptor.Reference, - Source = "ArIED signal model", - SclBType = descriptor.DataType, - MmsType = descriptor.DataType, - MmsTypeSignature = descriptor.DataType, - TypeDiscoveryStatus = "Projected", - TypeDiscoveryMessage = "Projected from the ArIED discovery signal row.", - TypeSource = "ArIED discovery", - TypeConfidence = LiveIedDiscoveryConfidenceLevel.High, - FunctionalConstraintConfidence = string.IsNullOrWhiteSpace(descriptor.FunctionalConstraint) - ? LiveIedDiscoveryConfidenceLevel.Unknown - : LiveIedDiscoveryConfidenceLevel.Exact - }) - .ToArray(); + if (!TryParseReference(signal.ObjectReference, out var parsed)) + yield break; - return new LiveIedDataObjectModel + if (!signal.IsControlSignal) { - Reference = primary.ObjectReference, - Name = dataObjectName, - ProposedDoTypeId = $"ARIED_DO_{SafeId(primary.Cdc)}_{SafeId(dataObjectName)}", - InferredCdc = primary.Cdc, - CdcConfidence = string.IsNullOrWhiteSpace(primary.Cdc) ? 0.5 : 0.9, - ConfidenceLevel = string.IsNullOrWhiteSpace(primary.Cdc) - ? LiveIedDiscoveryConfidenceLevel.Medium - : LiveIedDiscoveryConfidenceLevel.High, - Evidence = new[] { "Projected from ArIED live discovery signal metadata." }, - Attributes = attributes - }; + yield return new AttributeRow( + parsed.Domain, + parsed.LogicalNode, + parsed.DataObject, + parsed.AttributePath, + signal.ObjectReference, + signal.FunctionalConstraint, + NormalizeDataType(signal.DataType), + string.Empty); + yield break; + } + + if (TryParseReference(signal.ControlModelReference, out var ctlModel)) + { + yield return new AttributeRow( + ctlModel.Domain, + ctlModel.LogicalNode, + ctlModel.DataObject, + ctlModel.AttributePath, + signal.ControlModelReference, + "CF", + "Enum", + signal.ControlCdc); + } } - private static IReadOnlyList BuildDataSets(IReadOnlyList signals) + private static LiveIedDataSetModel[] BuildDataSets(IReadOnlyList signals) => signals .Where(signal => !string.IsNullOrWhiteSpace(signal.DataSetReference)) - .GroupBy(signal => signal.DataSetReference.Trim(), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .GroupBy(signal => signal.DataSetReference, StringComparer.OrdinalIgnoreCase) .Select(group => { - var reference = group.Key; - var domain = Domain(reference); - var tail = ReferenceTail(reference); - var separator = tail.LastIndexOf('.'); - var logicalNode = separator > 0 ? tail[..separator] : string.Empty; - var name = separator > 0 ? tail[(separator + 1)..] : tail; + ParseContainerReference(group.Key, out var domain, out var logicalNode, out var name); var members = group .Where(signal => !signal.IsControlSignal) - .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) - .Select(values => values.First()) - .Select((signal, index) => new LiveIedDataSetMemberModel + .GroupBy(signal => signal.ObjectReference, StringComparer.OrdinalIgnoreCase) + .Select((member, index) => new LiveIedDataSetMemberModel { Index = index + 1, - Reference = signal.ObjectReference, - FunctionalConstraint = signal.FunctionalConstraint, - MmsReference = signal.ObjectReference, + Reference = member.First().ObjectReference, + FunctionalConstraint = member.First().FunctionalConstraint, + MmsReference = member.First().ObjectReference, Confidence = LiveIedDiscoveryConfidenceLevel.High }) .ToArray(); - return new LiveIedDataSetModel { - Reference = reference, + Reference = group.Key, Domain = domain, LogicalNode = logicalNode, Name = name, @@ -170,107 +193,89 @@ private static IReadOnlyList BuildDataSets(IReadOnlyList BuildReportControls( - IReadOnlyList signals, - IReadOnlyList dataSets) - { - var dataSetIndex = dataSets.ToDictionary( - dataSet => NormalizeReference(dataSet.Reference), - dataSet => dataSet, - StringComparer.OrdinalIgnoreCase); - - return signals + private static LiveIedReportControlModel[] BuildReportControls(IReadOnlyList signals) + => signals .Where(signal => !string.IsNullOrWhiteSpace(signal.ReportControlReference)) - .GroupBy(signal => signal.ReportControlReference.Trim(), StringComparer.OrdinalIgnoreCase) - .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase) + .GroupBy(signal => signal.ReportControlReference, StringComparer.OrdinalIgnoreCase) .Select(group => { - var first = group.First(); - var reference = group.Key; - var tail = ReferenceTail(reference); - var name = tail.Contains('.') ? tail[(tail.LastIndexOf('.') + 1)..] : tail; - dataSetIndex.TryGetValue(NormalizeReference(first.DataSetReference), out var dataSet); + ParseContainerReference(group.Key, out var domain, out var logicalNode, out var name); + var reference = group.Key.Replace('$', '.'); return new LiveIedReportControlModel { - Reference = reference, - Domain = Domain(reference), - LogicalNode = tail.Contains('.') ? tail[..tail.IndexOf('.')] : "LLN0", + Reference = group.Key, + Domain = domain, + LogicalNode = logicalNode, Name = name, - Buffered = NormalizeReference(reference).Contains(".BR.", StringComparison.OrdinalIgnoreCase), - DataSetReference = dataSet?.Reference ?? first.DataSetReference, - Status = "Projected from ArIED discovery" + Buffered = reference.Contains(".BR.", StringComparison.OrdinalIgnoreCase), + DataSetReference = group.Select(signal => signal.DataSetReference) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty, + Status = "Projected from ARIEC61850 live signal bindings" }; }) .ToArray(); - } - private static LiveIedModelDiscoveryCoverage BuildCoverage( - IReadOnlyList logicalDevices, - IReadOnlyList dataSets, - IReadOnlyList reports) + private static bool TryParseReference(string? reference, out ParsedReference parsed) { - var logicalNodes = logicalDevices.SelectMany(device => device.LogicalNodes).ToArray(); - var dataObjects = logicalNodes.SelectMany(node => node.DataObjects).ToArray(); - var attributes = dataObjects.SelectMany(dataObject => dataObject.Attributes).ToArray(); - return new LiveIedModelDiscoveryCoverage - { - LogicalDeviceCount = logicalDevices.Count, - LogicalNodeCount = logicalNodes.Length, - DataObjectCount = dataObjects.Length, - DataAttributeCount = attributes.Length, - ExactFunctionalConstraintCount = attributes.Count(attribute => attribute.FunctionalConstraintConfidence == LiveIedDiscoveryConfidenceLevel.Exact), - ExactMmsTypeCount = attributes.Count(attribute => !string.IsNullOrWhiteSpace(attribute.MmsType)), - HighConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel is LiveIedDiscoveryConfidenceLevel.Exact or LiveIedDiscoveryConfidenceLevel.High), - MediumConfidenceCdcCount = dataObjects.Count(dataObject => dataObject.ConfidenceLevel == LiveIedDiscoveryConfidenceLevel.Medium), - DataSetCount = dataSets.Count, - ReportControlCount = reports.Count, - BufferedReportControlCount = reports.Count(report => report.Buffered), - UnbufferedReportControlCount = reports.Count(report => !report.Buffered) - }; - } - - private static SignalDescriptor? ToDescriptor(SignalDefinition signal) - { - var reference = signal.ObjectReference.Replace('$', '.').Trim(); - var slash = reference.IndexOf('/'); - if (slash <= 0 || slash >= reference.Length - 1) - return null; - - var domain = reference[..slash]; - var member = reference[(slash + 1)..]; - var segments = member.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (segments.Length < 2) - return null; + parsed = default; + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var slash = text.IndexOf('/'); + if (slash <= 0 || slash >= text.Length - 1) + return false; - var logicalNode = segments[0]; - var dataObject = segments[1]; - var attributePath = segments.Length > 2 ? string.Join('.', segments.Skip(2)) : string.Empty; - if (signal.IsControlSignal && string.IsNullOrWhiteSpace(attributePath)) - attributePath = "Oper.ctlVal"; + var domain = text[..slash]; + var parts = text[(slash + 1)..].Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 2) + return false; - return new SignalDescriptor( + parsed = new ParsedReference( domain, - logicalNode, - dataObject, - attributePath, - reference, - $"{domain}/{logicalNode}.{dataObject}", - signal.FunctionalConstraint, - signal.DataType, - signal.ControlCdc); + parts[0], + parts[1], + parts.Length > 2 ? string.Join(".", parts.Skip(2)) : "ctlModel"); + return true; } - private static string Domain(string reference) + private static void ParseContainerReference( + string reference, + out string domain, + out string logicalNode, + out string name) { - var slash = reference.IndexOf('/'); - return slash > 0 ? reference[..slash] : string.Empty; + var text = (reference ?? string.Empty).Trim().Replace('$', '.'); + var slash = text.IndexOf('/'); + domain = slash > 0 ? text[..slash] : string.Empty; + var remainder = slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text; + var parts = remainder.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + logicalNode = parts.Length > 0 ? parts[0] : string.Empty; + name = parts.Length > 0 ? parts[^1] : string.Empty; } - private static string ReferenceTail(string reference) + private static string ResolveLdInst(string domain, string iedName) + => !string.IsNullOrWhiteSpace(iedName) && domain.StartsWith(iedName, StringComparison.OrdinalIgnoreCase) + ? domain[iedName.Length..] + : domain; + + private static string NormalizeDataType(string? dataType) { - var text = reference.Replace('$', '.'); - var slash = text.IndexOf('/'); - return slash >= 0 && slash < text.Length - 1 ? text[(slash + 1)..] : text; + var value = (dataType ?? string.Empty).Trim(); + var separator = value.IndexOf(' '); + return separator > 0 ? value[..separator] : value; } - private static string LogicalDeviceInst(string domain, string iedName) + private readonly record struct ParsedReference( + string Domain, + string LogicalNode, + string DataObject, + string AttributePath); + + private readonly record struct AttributeRow( + string Domain, + string LogicalNode, + string DataObject, + string AttributePath, + string Reference, + string FunctionalConstraint, + string DataType, + string Cdc); +} From ea7378157d1add4e96d8104c9dd34e5c1cb9704a Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:23:36 +0700 Subject: [PATCH 19/21] ci: remove focused compile gate after successful validation --- .github/workflows/compile-scl-integration.yml | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/workflows/compile-scl-integration.yml diff --git a/.github/workflows/compile-scl-integration.yml b/.github/workflows/compile-scl-integration.yml deleted file mode 100644 index e801e35f..00000000 --- a/.github/workflows/compile-scl-integration.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Compile SCL integration - -on: - pull_request: - branches: [ main ] - -jobs: - compile: - if: ${{ github.event.pull_request.head.ref == 'agent/use-engine-scl-workspace' }} - runs-on: windows-latest - steps: - - name: Checkout ArIED branch - shell: powershell - run: git clone --quiet --depth 1 --branch agent/use-engine-scl-workspace https://github.com/masarray/ArIED61850Tester.git ArIED61850Tester - - - name: Checkout engine workspace branch - shell: powershell - run: git clone --quiet --depth 1 --branch agent/scl-workspace-api https://github.com/masarray/ARIEC61850.git ARIEC61850 - - - name: Setup .NET 8 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Compile - shell: powershell - run: dotnet build .\ArIED61850Tester\ArIED61850Tester.csproj -c Release -v:q --nologo From 12174511c72ed33d4e23e1877b685ad7a9ee1b01 Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:28:38 +0700 Subject: [PATCH 20/21] chore: bump SCL workspace integration to 1.6.6 --- ArIED61850Tester.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index e13c87c9..7b3b99e8 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -13,11 +13,11 @@ Copyright (C) 2026 Mas Ari / masarray ArIED 61850 ArIED 61850 - Smart IED Explorer & Monitor for live IEC 61850 discovery, multi-IED monitoring, reporting, control-object inspection, quality, timestamps, and event logging. - 1.6.5 - 1.6.5.0 - 1.6.5.0 - iec61850;iec-61850;mms;ied;relay-testing;substation-automation;digital-substation;report-control-block;wpf;dotnet + Smart IED Explorer & Monitor for SCL-based offline engineering, live IEC 61850 discovery, multi-IED monitoring, reporting, control-object inspection, quality, timestamps, and event logging. + 1.6.6 + 1.6.6.0 + 1.6.6.0 + iec61850;iec-61850;mms;scl;ied;relay-testing;substation-automation;digital-substation;report-control-block;wpf;dotnet GPL-3.0-or-later $(ARIEC61850_PROJECT) ..\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj From 093895a88ddad6d5492b1c6527f97a70f9182cde Mon Sep 17 00:00:00 2001 From: masarray Date: Tue, 14 Jul 2026 11:28:54 +0700 Subject: [PATCH 21/21] chore: align portable publish default with 1.6.6 --- scripts/publish-windows-portable.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index e19dde57..6b8bc160 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.6.5", + [string]$Version = "1.6.6", [string]$Runtime = "win-x64", [bool]$SingleFile = $true, [bool]$SelfContained = $true, @@ -23,7 +23,7 @@ if ($normalizedVersion.StartsWith("v", [System.StringComparison]::OrdinalIgnoreC $normalizedVersion = $normalizedVersion.Substring(1) } if ($normalizedVersion -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { - throw "Invalid version '$Version'. Use a value such as 1.6.5 or v1.6.5." + throw "Invalid version '$Version'. Use a value such as 1.6.6 or v1.6.6." } $outputRoot = Join-Path $root "dist"