diff --git a/MainWindow.Demo.cs b/MainWindow.Demo.cs new file mode 100644 index 000000000..136b8361b --- /dev/null +++ b/MainWindow.Demo.cs @@ -0,0 +1,193 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private const string DemoShortcutText = "Ctrl+Shift+D"; + private readonly DispatcherTimer _demoTimer = new(DispatcherPriority.Background); + private readonly Random _demoRandom = new(61850); + private readonly List _demoPointStates = new(); + private readonly List _demoGooseStates = new(); + private bool _isDemoMode; + private int _demoTick; + private long _demoEventSequence = 5000; + + public bool IsDemoMode => _isDemoMode; + public Visibility DemoModeVisibility => _isDemoMode ? Visibility.Visible : Visibility.Collapsed; + public string DemoModeText => $"DEMO MODE • {DemoShortcutText}"; + + private void MainWindow_PreviewKeyDown(object sender, KeyEventArgs e) + { + if (Keyboard.Modifiers != (ModifierKeys.Control | ModifierKeys.Shift) || e.Key != Key.D) + return; + + e.Handled = true; + ToggleDemoMode(); + } + + private void ToggleDemoMode() + { + if (_isDemoMode) + { + DeactivateDemoMode(); + return; + } + + if (Devices.Any(device => device.IsConnected || device.IsMonitoring || device.IsBusy) || IsGooseCapturing) + { + MessageBox.Show( + this, + "Disconnect every live IED and stop GOOSE capture before entering Demo Mode.", + "Demo Mode", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + if (Devices.Count > 0 || GlobalPoints.Count > 0 || Events.Count > 0) + { + var choice = MessageBox.Show( + this, + "Demo Mode replaces the current offline workspace with synthetic substation data. Save the current project first if it must be retained.\n\nContinue?", + "Load Demo Workspace", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (choice != MessageBoxResult.Yes) + return; + } + + ActivateDemoMode(); + } + + private void ActivateDemoMode() + { + ClearWorkspaceForDemo(); + _isDemoMode = true; + _demoTick = 0; + _demoEventSequence = 5000; + Title = "ArIED 61850 — Synthetic Substation Demo"; + + InstallGoosePresentationWorkspace(); + BuildDemoDevicesAndLivePoints(); + BuildDemoEventHistory(); + BuildDemoGooseWorkspace(); + BuildDemoDiagnostics(); + + SelectedDevice = Devices.FirstOrDefault(); + MainTabs.SelectedIndex = 0; + UpdateNavigationVisuals(0, animate: false); + + _demoTimer.Stop(); + _demoTimer.Interval = TimeSpan.FromMilliseconds(1100); + _demoTimer.Tick -= DemoTimer_Tick; + _demoTimer.Tick += DemoTimer_Tick; + _demoTimer.Start(); + + Raise(nameof(IsDemoMode)); + Raise(nameof(DemoModeVisibility)); + Raise(nameof(DemoModeText)); + RaiseWorkspaceCounts(); + SetStatus($"DEMO MODE active • 10 synthetic IEDs • {GlobalPoints.Count:N0} live values • {GooseStreams.Count:N0} GOOSE publishers • press {DemoShortcutText} to exit."); + } + + private void DeactivateDemoMode() + { + _demoTimer.Stop(); + IsGooseCapturing = false; + _isDemoMode = false; + Title = "ArIED 61850 — Smart IED Explorer & Monitor"; + ClearWorkspaceForDemo(); + AddLog("INFO", "Demo", "Synthetic substation demo closed. No network sessions were created."); + Raise(nameof(IsDemoMode)); + Raise(nameof(DemoModeVisibility)); + Raise(nameof(DemoModeText)); + RaiseWorkspaceCounts(); + SetStatus($"Demo workspace cleared. Press {DemoShortcutText} to load it again."); + } + + private void ClearWorkspaceForDemo() + { + _demoTimer.Stop(); + SelectedDevice = null; + + foreach (var device in Devices) + DetachSignalHandlers(device.Signals); + + Devices.Clear(); + GlobalPoints.Clear(); + Events.Clear(); + Logs.Clear(); + _signalOwners.Clear(); + _pointIndex.Clear(); + _controlFeedbackIndex.Clear(); + _pendingPointSnapshots.Clear(); + _reportPulseUntil.Clear(); + _pointHighlightUntil.Clear(); + while (_pendingEvents.TryDequeue(out _)) { } + while (_pendingDiagnostics.TryDequeue(out _)) { } + + _demoPointStates.Clear(); + _demoGooseStates.Clear(); + ResetGooseView(resetCounters: true); + ResetGooseTimelineUi(); + GooseAdapters.Clear(); + SelectedGooseAdapter = null; + } + + private void BuildDemoDevicesAndLivePoints() + { + var deviceSpecs = new[] + { + new DemoDeviceSpec("BCU_INCOMER_150KV", "BCU • 150 kV line incomer", "192.168.10.11", "3 LD • 29 LN • 186 DO • 1,042 DA", DemoDeviceRole.Bcu), + new DemoDeviceSpec("BCU_TRAFO_BAY_TR1", "BCU • transformer HV bay", "192.168.10.12", "3 LD • 31 LN • 194 DO • 1,108 DA", DemoDeviceRole.Bcu), + new DemoDeviceSpec("PROT_OCR_INCOMER_20KV", "OCR/GFR • 20 kV incomer", "192.168.10.21", "2 LD • 26 LN • 171 DO • 936 DA", DemoDeviceRole.Ocr), + new DemoDeviceSpec("PROT_OCR_FEEDER_F01", "OCR/GFR • 20 kV feeder", "192.168.10.22", "2 LD • 24 LN • 158 DO • 874 DA", DemoDeviceRole.Ocr), + new DemoDeviceSpec("PROT_LINE_DIFF_L01", "87L line differential", "192.168.10.31", "3 LD • 34 LN • 221 DO • 1,294 DA", DemoDeviceRole.LineDiff), + new DemoDeviceSpec("PROT_DISTANCE_L02", "21 distance protection", "192.168.10.32", "3 LD • 33 LN • 216 DO • 1,247 DA", DemoDeviceRole.Distance), + new DemoDeviceSpec("PROT_TRAFO_DIFF_TR1", "87T transformer differential", "192.168.10.41", "3 LD • 38 LN • 248 DO • 1,462 DA", DemoDeviceRole.TrafoDiff), + new DemoDeviceSpec("PROT_BUSBAR_DIFF_BB1", "87B busbar differential", "192.168.10.51", "4 LD • 46 LN • 312 DO • 1,856 DA", DemoDeviceRole.BusbarDiff), + new DemoDeviceSpec("PROT_CAPBANK_CB1", "capacitor-bank protection", "192.168.10.61", "2 LD • 25 LN • 164 DO • 902 DA", DemoDeviceRole.CapBank), + new DemoDeviceSpec("BCU_BUS_COUPLER_150KV", "BCU • 150 kV bus coupler", "192.168.10.71", "3 LD • 30 LN • 191 DO • 1,081 DA", DemoDeviceRole.Coupler) + }; + + for (var deviceIndex = 0; deviceIndex < deviceSpecs.Length; deviceIndex++) + { + var spec = deviceSpecs[deviceIndex]; + var device = new Iec61850MonitorDevice + { + DeviceId = $"demo-{deviceIndex + 1:00}-{spec.Name.ToLowerInvariant()}", + Name = spec.Name, + IdentitySource = $"DEMO • live MMS discovery • {spec.Description}", + LogicalDeviceSummary = spec.ModelSummary, + IpAddress = spec.IpAddress, + Port = 102, + Status = "Monitoring • BRCB active", + Detail = $"Synthetic {spec.Description} session. Buffered reports, MMS validation reads and GOOSE bindings are generated locally; no packets are transmitted.", + AcquisitionMode = deviceIndex % 3 == 0 + ? "Buffered Report • dchg/qchg/dupd" + : deviceIndex % 3 == 1 + ? "Unbuffered Report • dchg + integrity" + : "Static DataSet report • MMS verification", + HasDiscoveryCache = true, + IsConnected = true, + IsMonitoring = true, + HasReportStream = true, + IsDemo = true + }; + + var seeds = BuildDemoSignalSeeds(spec.Role, deviceIndex); + foreach (var seed in seeds) + AddDemoPoint(device, seed, deviceIndex); + + device.RecountSelectedSignals(); + device.AddUnreadEvents(1 + deviceIndex % 4); + Devices.Add(device); + } + } +} diff --git a/MainWindow.DemoGooseData.cs b/MainWindow.DemoGooseData.cs new file mode 100644 index 000000000..c348c3c45 --- /dev/null +++ b/MainWindow.DemoGooseData.cs @@ -0,0 +1,122 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private void BuildDemoGooseWorkspace() + { + GooseAdapters.ReplaceAll(new[] + { + new GooseAdapterOption + { + Index = 1, + Name = "demo://station-bus", + FriendlyName = "Station Bus VLAN 100 (Synthetic)", + Description = "ArIED built-in demonstration adapter", + MacAddress = "02:00:5E:61:85:00" + } + }); + SelectedGooseAdapter = GooseAdapters[0]; + IsGooseCapturing = true; + GooseBindingText = "DEMO model binding • 6 publishers • ordered DataSet leaves resolved from synthetic IED models"; + GooseStatusText = "DEMO • synthetic read-only GOOSE capture is running; no Ethernet frames are transmitted or received."; + + var streamSpecs = new[] + { + DemoGooseSpec("gcb-incomer", "BCU_INCOMER_150KV", "0x1001", "01:0C:CD:01:00:01", 100, "gcbTripStatus", "dsTripStatus", + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]"), + ("Master trip", "PTRC1.Tr.general", "ACT / BOOLEAN", "false"), + ("Close interlock release", "CILO1.EnaCls.stVal", "SPS / BOOLEAN", "true"), + ("Control authority", "CSWI1.Loc.stVal", "ENS / INT32", "Remote")), + DemoGooseSpec("gcb-linediff", "PROT_LINE_DIFF_L01", "0x1101", "01:0C:CD:01:01:01", 110, "gcbLineProtection", "dsLineProtection", + ("Line differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Line differential trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Teleprotection receive", "PSCH1.Op.general", "ACT / BOOLEAN", "false"), + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")), + DemoGooseSpec("gcb-trafodiff", "PROT_TRAFO_DIFF_TR1", "0x1201", "01:0C:CD:01:02:01", 120, "gcbTransformerTrip", "dsTransformerTrip", + ("Transformer differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Transformer differential trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Inrush restraint", "PHAR1.Str.general", "ACD / BOOLEAN", "false"), + ("HV breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")), + DemoGooseSpec("gcb-busbar", "PROT_BUSBAR_DIFF_BB1", "0x1301", "01:0C:CD:01:03:01", 130, "gcbBusbarTrip", "dsBusbarTrip", + ("Busbar differential pickup", "PDIF1.Str.general", "ACD / BOOLEAN", "false"), + ("Busbar zone 1 trip", "PDIF1.Op.general", "ACT / BOOLEAN", "false"), + ("Breaker-failure initiate", "RBRF1.OpEx.general", "ACT / BOOLEAN", "false"), + ("Zone healthy", "GGIO1.Ind1.stVal", "SPS / BOOLEAN", "true")), + DemoGooseSpec("gcb-coupler", "BCU_BUS_COUPLER_150KV", "0x1401", "01:0C:CD:01:04:01", 140, "gcbCouplerInterlock", "dsCouplerInterlock", + ("Coupler position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Open [01]"), + ("Synchrocheck release", "RSYN1.Rel.stVal", "SPS / BOOLEAN", "true"), + ("Bus 1 voltage healthy", "GGIO1.Ind2.stVal", "SPS / BOOLEAN", "true"), + ("Bus 2 voltage healthy", "GGIO1.Ind3.stVal", "SPS / BOOLEAN", "true")), + DemoGooseSpec("gcb-feeder", "PROT_OCR_FEEDER_F01", "0x1501", "01:0C:CD:01:05:01", 150, "gcbFeederProtection", "dsFeederProtection", + ("Overcurrent pickup", "PTOC1.Str.general", "ACD / BOOLEAN", "false"), + ("Earth-fault pickup", "PTOC3.Str.general", "ACD / BOOLEAN", "false"), + ("Master trip", "PTRC1.Tr.general", "ACT / BOOLEAN", "false"), + ("Breaker position", "XCBR1.Pos.stVal", "DPC / Dbpos", "Closed [10]")) + }; + + var baseTime = DateTimeOffset.Now.AddMinutes(-4); + for (var index = 0; index < streamSpecs.Length; index++) + { + var state = new DemoGooseStreamState(streamSpecs[index], stateNumber: 12 + index * 3, sequenceNumber: 510 + index * 71, packetCount: 8200 + index * 1260); + var row = new GooseStreamRow { StreamKey = state.Spec.StreamKey }; + state.Row = row; + row.Apply(BuildDemoGooseSnapshot(state, changedIndex: -1, diagnostics: string.Empty, baseTime.AddSeconds(index * 4))); + GooseStreams.Add(row); + _gooseStreamIndex[row.StreamKey] = row; + _demoGooseStates.Add(state); + } + + for (var index = 0; index < 30; index++) + { + var state = _demoGooseStates[index % _demoGooseStates.Count]; + var timestamp = baseTime.AddSeconds(index * 8.2); + var leaf = state.Leaves[index % state.Leaves.Count]; + GooseEvents.Add(new GooseEventRow + { + StreamKey = state.Spec.StreamKey, + Timestamp = timestamp, + DeltaText = index < _demoGooseStates.Count ? "-" : "8.200 s", + EventText = index % 11 == 0 ? "Warning" : "State change", + EventTone = index % 11 == 0 ? "Warning" : "Change", + Publisher = state.Spec.IedName, + StateSequenceText = $"{state.StateNumber + index / _demoGooseStates.Count} / 0", + Summary = index % 11 == 0 + ? "TAL supervision recovered after one delayed retransmission" + : $"{leaf.Name}: {ToggleDemoValue(leaf.Value)}" + }); + } + + SelectedGooseStream = GooseStreams.FirstOrDefault(); + SelectedGooseEvent = GooseEvents.LastOrDefault(); + GooseCapturedFrames = _demoGooseStates.Sum(state => state.PacketCount) + 412; + GooseFrames = GooseCapturedFrames; + GooseOtherFrames = 0; + Raise(nameof(GooseCounterText)); + Raise(nameof(GooseNoStreamsVisibility)); + Raise(nameof(GooseNoLeafValuesVisibility)); + RaiseGoosePresentationState(); + } + + private void BuildDemoDiagnostics() + { + var now = DateTime.Now; + var logs = new List + { + new() { Time = now.AddMinutes(-8), Level = "INFO", Source = "Demo", Message = "Synthetic substation workspace initialized. Network transport is disabled." }, + new() { Time = now.AddMinutes(-7.8), Level = "INFO", Source = "MMS", Message = "10 independent IEC 61850 associations established on TCP/102 (synthetic)." }, + new() { Time = now.AddMinutes(-7.6), Level = "INFO", Source = "Discovery", Message = "IEDName, LD/LN/DO/DA, DataSets, RCBs and GSEControl models resolved for all demo IEDs." }, + new() { Time = now.AddMinutes(-7.4), Level = "INFO", Source = "Reporting", Message = "6 BRCB and 4 URCB sessions enabled; dchg/qchg/dupd and integrity reasons validated." }, + new() { Time = now.AddMinutes(-7.2), Level = "INFO", Source = "GOOSE", Message = "6 station-bus publishers bound to ordered DataSet leaves on VLANs 100-150." }, + new() { Time = now.AddMinutes(-4.1), Level = "WARN", Source = "GOOSE", Message = "One delayed retransmission exceeded the expected interval; TAL supervision recovered without state loss." }, + new() { Time = now.AddMinutes(-3.9), Level = "INFO", Source = "GOOSE", Message = "Publisher sequence returned to normal; stNum and sqNum continuity verified." }, + new() { Time = now.AddMinutes(-1.2), Level = "INFO", Source = "Reporting", Message = "Periodic integrity report completed for PROT_TRAFO_DIFF_TR1; 12 values confirmed good." } + }; + Logs.AddRange(logs); + } +} diff --git a/MainWindow.DemoGooseSnapshot.cs b/MainWindow.DemoGooseSnapshot.cs new file mode 100644 index 000000000..27fcfd7d1 --- /dev/null +++ b/MainWindow.DemoGooseSnapshot.cs @@ -0,0 +1,70 @@ +using System.Globalization; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private GooseStreamSnapshot BuildDemoGooseSnapshot(DemoGooseStreamState state, int changedIndex, string diagnostics, DateTimeOffset timestamp) + { + var leaves = state.Leaves.Select((leaf, index) => new GooseLeafValueSnapshot( + index + 1, + index, + leaf.Name, + $"{state.Spec.IedName}LD0/{leaf.Path}", + leaf.Path.Contains(".mag.", StringComparison.OrdinalIgnoreCase) ? "MX" : "ST", + leaf.TypeText.Split('/')[0].Trim(), + leaf.TypeText.Contains('/') ? leaf.TypeText.Split('/')[1].Trim() : string.Empty, + leaf.Value, + index == changedIndex ? leaf.PreviousValue : leaf.Value, + index == changedIndex, + "DEMO model")).ToArray(); + + return new GooseStreamSnapshot( + state.Spec.StreamKey, + state.Spec.AppId, + $"{state.Spec.IedName}LD0/LLN0$GO${state.Spec.ControlBlock}", + $"{state.Spec.IedName}_{state.Spec.ControlBlock}", + $"{state.Spec.IedName}LD0/LLN0$DataSet${state.Spec.DataSet}", + state.Spec.SourceMac, + state.Spec.DestinationMac, + $"VID {state.Spec.Vlan} / PCP 4", + state.StateNumber.ToString(CultureInfo.InvariantCulture), + state.SequenceNumber.ToString(CultureInfo.InvariantCulture), + changedIndex >= 0 ? "StateChange" : "Retransmission", + "2000 ms", + "1", + state.Spec.IedName, + "DEMO model", + diagnostics, + timestamp.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), + state.PacketCount, + changedIndex >= 0 ? 1 : 0, + false, + false, + leaves); + } + + private static DemoGooseStreamSpec DemoGooseSpec( + string streamKey, + string iedName, + string appId, + string destinationMac, + int vlan, + string controlBlock, + string dataSet, + params (string Name, string Path, string TypeText, string InitialValue)[] leaves) + { + var lastOctet = (Math.Abs((long)iedName.GetHashCode(StringComparison.Ordinal)) % 180) + 20; + return new DemoGooseStreamSpec( + streamKey, + iedName, + appId, + $"02:00:5E:61:85:{lastOctet:X2}", + destinationMac, + vlan, + controlBlock, + dataSet, + leaves.Select(leaf => new DemoGooseLeafState(leaf.Name, leaf.Path, leaf.TypeText, leaf.InitialValue)).ToArray()); + } +} diff --git a/MainWindow.DemoProcessRuntime.cs b/MainWindow.DemoProcessRuntime.cs new file mode 100644 index 000000000..94851b2d6 --- /dev/null +++ b/MainWindow.DemoProcessRuntime.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private void DemoTimer_Tick(object? sender, EventArgs e) + { + if (!_isDemoMode || _shutdownStarted) + return; + + _demoTick++; + UpdateDemoAnalogValues(); + if (_demoTick % 3 == 0) + GenerateDemoProcessEvent(); + RaiseWorkspaceCounts(); + } + + private void UpdateDemoAnalogValues() + { + var analogStates = _demoPointStates.Where(state => state.Seed.Kind == DemoValueKind.Analog).ToArray(); + if (analogStates.Length == 0) + return; + + var updateCount = Math.Min(12, analogStates.Length); + for (var index = 0; index < updateCount; index++) + { + var state = analogStates[(_demoTick * 7 + index * 5) % analogStates.Length]; + var phase = (_demoTick + state.Point.Sequence % 17) * 0.21; + var noise = (_demoRandom.NextDouble() - 0.5) * state.Seed.Variation * 0.18; + var value = state.Seed.BaseValue + Math.Sin(phase) * state.Seed.Variation * 0.55 + noise; + var formatted = FormatDemoAnalog(value, state.Seed.Unit); + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(8, 95)); + + state.Point.Value = formatted; + state.Point.Quality = "Good • validity=good"; + state.Point.DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + state.Point.SourceMode = _demoTick % 5 == 0 ? "MMS validation read" : "Buffered Report • dupd"; + state.Point.Reason = _demoTick % 5 == 0 ? "verification" : "dupd"; + state.Point.Sequence++; + state.Signal.Value = formatted; + state.Signal.Quality = state.Point.Quality; + state.Signal.DeviceTimestamp = state.Point.DeviceTimestamp; + state.Signal.Timestamp = timestamp; + state.Device.ReportPulseActive = true; + _reportPulseUntil[state.Device.DeviceId] = DateTime.UtcNow.AddMilliseconds(520); + } + } + + private void GenerateDemoProcessEvent() + { + var candidates = _demoPointStates.Where(state => state.Seed.EventEligible && state.Seed.DiscreteValues is { Length: > 1 }).ToArray(); + if (candidates.Length == 0) + return; + + var state = candidates[(_demoTick / 3) % candidates.Length]; + var values = state.Seed.DiscreteValues!; + var currentIndex = Array.FindIndex(values, value => value.Equals(state.Point.Value, StringComparison.OrdinalIgnoreCase)); + if (currentIndex < 0) + currentIndex = 0; + var nextValue = values[(currentIndex + 1) % values.Length]; + var oldValue = state.Point.Value; + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(5, 45)); + + state.Point.Value = nextValue; + state.Point.Quality = "Good • validity=good"; + state.Point.DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + state.Point.SourceMode = "Buffered Report • dchg"; + state.Point.Reason = "dchg"; + state.Point.Sequence++; + state.Point.IsRecentlyChanged = true; + state.Signal.Value = nextValue; + state.Signal.Quality = state.Point.Quality; + state.Signal.DeviceTimestamp = state.Point.DeviceTimestamp; + state.Signal.Timestamp = timestamp; + state.Device.ReportPulseActive = true; + _reportPulseUntil[state.Device.DeviceId] = DateTime.UtcNow.AddMilliseconds(650); + _pointHighlightUntil[state.Point.PointKey] = DateTime.UtcNow.AddSeconds(3); + + var demoEvent = CreateDemoEvent(state, oldValue, nextValue, timestamp); + Events.AddRange(new[] { demoEvent }); + Events.TrimStart(10000); + if (MainTabs.SelectedIndex != 2) + state.Device.AddUnreadEvents(1); + LastStatusText = $"DEMO EVENT • {state.Device.Name} / {state.Point.SignalName} = {nextValue}"; + } + + private Iec61850EventEntry CreateDemoEvent(DemoPointState state, string oldValue, string newValue, DateTime timestamp) + => new() + { + Sequence = ++_demoEventSequence, + DeviceId = state.Device.DeviceId, + PointKey = state.Point.PointKey, + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + DeviceName = state.Device.Name, + IpAddress = state.Device.IpAddress, + SignalName = state.Point.SignalName, + IecReference = state.Point.IecReference, + OldValue = oldValue, + NewValue = newValue, + Quality = "Good • validity=good", + SourceMode = "Buffered Report • dchg", + Reason = "dchg" + }; +} diff --git a/MainWindow.DemoSignals.cs b/MainWindow.DemoSignals.cs new file mode 100644 index 000000000..fd5687316 --- /dev/null +++ b/MainWindow.DemoSignals.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private IReadOnlyList BuildDemoSignalSeeds(DemoDeviceRole role, int index) + { + var common = new List + { + Position("Breaker position", "XCBR1.Pos.stVal", index % 4 == 3 ? "Open [01]" : "Closed [10]"), + Boolean("Trip circuit healthy", "GGIO1.Ind1.stVal", true, eventEligible: false), + Boolean("Protection enabled", "LLN0.Mod.stVal", true, eventEligible: false), + State("Control authority", "CSWI1.Loc.stVal", new[] { "Remote", "Local" }, "Remote", eventEligible: false), + Analog("Phase A current", "MMXU1.A.phsA.cVal.mag.f", "A", 620 + index * 38, 34), + Analog("Phase B current", "MMXU1.A.phsB.cVal.mag.f", "A", 608 + index * 36, 32), + Analog("Phase C current", "MMXU1.A.phsC.cVal.mag.f", "A", 631 + index * 35, 36), + Analog("Bus voltage", "MMXU1.PhV.phsA.cVal.mag.f", "kV", index is 2 or 3 or 8 ? 20.4 : 149.7, index is 2 or 3 or 8 ? 0.22 : 0.55) + }; + + common.AddRange(role switch + { + DemoDeviceRole.Bcu => new[] + { + Boolean("Open interlock release", "CILO1.EnaOpn.stVal", true, false), + Boolean("Close interlock release", "CILO1.EnaCls.stVal", true, false), + Counter("Breaker operation count", "XCBR1.OpCnt.stVal", 184 + index * 11), + Boolean("Bay alarm summary", "GGIO1.Alm1.stVal", false, true) + }, + DemoDeviceRole.Ocr => new[] + { + Boolean("Overcurrent stage 1 pickup", "PTOC1.Str.general", false, true), + Boolean("Overcurrent stage 2 trip", "PTOC2.Op.general", false, true), + Boolean("Earth-fault pickup", "PTOC3.Str.general", false, true), + Boolean("Master trip", "PTRC1.Tr.general", false, true) + }, + DemoDeviceRole.LineDiff => new[] + { + Boolean("Line differential pickup", "PDIF1.Str.general", false, true), + Boolean("Line differential trip", "PDIF1.Op.general", false, true), + Boolean("Teleprotection receive", "PSCH1.Op.general", false, true), + Analog("Differential current", "PDIF1.Idiff.mag.f", "A", 0.18, 0.08) + }, + DemoDeviceRole.Distance => new[] + { + Boolean("Distance zone 1 pickup", "PDIS1.Str.general", false, true), + Boolean("Distance zone 2 pickup", "PDIS2.Str.general", false, true), + Boolean("Power swing blocking", "RPSB1.BlkZn.general", false, true), + Analog("Positive-sequence impedance", "MMXU2.Z.phsA.cVal.mag.f", "Ω", 38.6, 0.9) + }, + DemoDeviceRole.TrafoDiff => new[] + { + Boolean("Transformer differential pickup", "PDIF1.Str.general", false, true), + Boolean("Transformer differential trip", "PDIF1.Op.general", false, true), + Boolean("Inrush restraint", "PHAR1.Str.general", false, true), + Analog("Winding temperature", "STMP1.Tmp.mag.f", "°C", 57.8, 1.7) + }, + DemoDeviceRole.BusbarDiff => new[] + { + Boolean("Busbar differential pickup", "PDIF1.Str.general", false, true), + Boolean("Busbar zone 1 trip", "PDIF1.Op.general", false, true), + Boolean("Breaker-failure initiate", "RBRF1.OpEx.general", false, true), + Analog("Zone differential current", "PDIF1.Idiff.mag.f", "A", 0.11, 0.06) + }, + DemoDeviceRole.CapBank => new[] + { + Boolean("Capacitor unbalance pickup", "PTOC1.Str.general", false, true), + Boolean("Overvoltage trip", "PTOV1.Op.general", false, true), + Boolean("Undervoltage blocking", "PTUV1.Str.general", false, true), + Analog("Reactive power", "MMXU1.VAr.threePh.cVal.mag.f", "MVAr", 18.4, 0.8) + }, + DemoDeviceRole.Coupler => new[] + { + Boolean("Synchrocheck release", "RSYN1.Rel.stVal", true, false), + Boolean("Bus 1 voltage healthy", "GGIO1.Ind2.stVal", true, false), + Boolean("Bus 2 voltage healthy", "GGIO1.Ind3.stVal", true, false), + Analog("Phase-angle difference", "RSYN1.DifAng.mag.f", "°", 1.8, 0.6) + }, + _ => Array.Empty() + }); + + return common; + } + + private void AddDemoPoint(Iec61850MonitorDevice device, DemoSignalSeed seed, int deviceIndex) + { + var reference = $"{device.Name}LD0/{seed.Path}"; + var sidecarRoot = BuildSidecarRoot(reference); + var dataSet = $"{device.Name}LD0/LLN0$DataSet$dsOperational"; + var rcb = $"{device.Name}LD0/LLN0$BR$brcbOperational01"; + var timestamp = DateTime.Now.AddMilliseconds(-_demoRandom.Next(20, 780)); + + var signal = new SignalDefinition + { + Name = seed.Name, + ObjectReference = reference, + DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(reference, device.Name), + FunctionalConstraint = seed.FunctionalConstraint, + DataType = seed.DataType, + Category = seed.Category, + Unit = seed.Unit, + Confidence = "High", + DataSetReference = dataSet, + ReportControlReference = rcb, + ReportCoverageReason = "Configured operational DataSet with active report control block.", + QualityReference = sidecarRoot + ".q", + TimestampReference = sidecarRoot + ".t", + Source = "DEMO • live discovery", + IsSelected = true, + IsReportCapable = true, + ReportCoverage = deviceIndex % 2 == 0 ? "Buffered Report • dchg/qchg" : "Unbuffered Report • dchg/dupd", + Value = seed.InitialValue, + Quality = "Good • validity=good", + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + ProbeStatus = "Read OK", + Timestamp = timestamp + }; + + var point = new Iec61850MonitorPoint + { + DeviceId = device.DeviceId, + DeviceName = device.Name, + IpAddress = device.IpAddress, + SignalName = seed.Name, + IecReference = reference, + QualityReference = sidecarRoot + ".q", + TimestampReference = sidecarRoot + ".t", + FunctionalConstraint = seed.FunctionalConstraint, + IecDataType = seed.DataType, + Category = seed.Category, + Unit = seed.Unit, + DataSetReference = dataSet, + ReportControlReference = rcb, + PollingIntervalMs = 1000, + Value = seed.InitialValue, + Quality = "Good • validity=good", + DeviceTimestamp = timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + SourceMode = deviceIndex % 2 == 0 ? "Buffered Report • dchg" : "Unbuffered Report • dupd", + Reason = seed.EventEligible ? "dchg" : "integrity", + Status = "Live", + Sequence = 1000 + _demoPointStates.Count + }; + + signal.PropertyChanged += Signal_PropertyChanged; + _signalOwners[signal] = device; + device.Signals.Add(signal); + device.Points.Add(point); + GlobalPoints.Add(point); + _pointIndex[point.PointKey] = point; + _demoPointStates.Add(new DemoPointState(device, signal, point, seed)); + } + + private void BuildDemoEventHistory() + { + var candidates = _demoPointStates.Where(state => state.Seed.EventEligible).ToArray(); + var events = new List(); + var now = DateTime.Now; + + for (var index = 0; index < 54; index++) + { + var state = candidates[index % candidates.Length]; + var values = state.Seed.DiscreteValues ?? new[] { "false", "true" }; + var oldValue = values[index % values.Length]; + var newValue = values[(index + 1) % values.Length]; + var eventTime = now.AddSeconds(-(54 - index) * 17 - _demoRandom.Next(0, 8)); + events.Add(CreateDemoEvent(state, oldValue, newValue, eventTime)); + } + + Events.AddRange(events.OrderBy(item => item.DeviceTimestamp, StringComparer.Ordinal)); + } +} diff --git a/MainWindow.DemoTypes.cs b/MainWindow.DemoTypes.cs new file mode 100644 index 000000000..875601e30 --- /dev/null +++ b/MainWindow.DemoTypes.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private static string ToggleDemoValue(string value) + { + var normalized = value.Trim(); + if (normalized.Equals("false", StringComparison.OrdinalIgnoreCase)) return "true"; + if (normalized.Equals("true", StringComparison.OrdinalIgnoreCase)) return "false"; + if (normalized.Contains("Closed", StringComparison.OrdinalIgnoreCase)) return "Open [01]"; + if (normalized.Contains("Open", StringComparison.OrdinalIgnoreCase)) return "Closed [10]"; + if (normalized.Equals("Remote", StringComparison.OrdinalIgnoreCase)) return "Local"; + if (normalized.Equals("Local", StringComparison.OrdinalIgnoreCase)) return "Remote"; + return normalized; + } + + private static string BuildSidecarRoot(string reference) + { + foreach (var suffix in new[] { ".stVal", ".mag.f", ".general" }) + { + if (reference.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + return reference[..^suffix.Length]; + } + var lastDot = reference.LastIndexOf('.'); + return lastDot > 0 ? reference[..lastDot] : reference; + } + + private static string FormatDemoAnalog(double value, string unit) + { + var decimals = Math.Abs(value) < 10 ? "0.00" : "0.0"; + return string.IsNullOrWhiteSpace(unit) + ? value.ToString(decimals, CultureInfo.InvariantCulture) + : $"{value.ToString(decimals, CultureInfo.InvariantCulture)} {unit}"; + } + + private static DemoSignalSeed Position(string name, string path, string initial) + => new(name, path, "ST", "Dbpos", "Status", string.Empty, initial, DemoValueKind.Discrete, 0, 0, new[] { "Open [01]", "Closed [10]" }, true); + + private static DemoSignalSeed Boolean(string name, string path, bool initial, bool eventEligible) + => new(name, path, "ST", "BOOLEAN", "Status", string.Empty, initial ? "true" : "false", DemoValueKind.Discrete, 0, 0, new[] { "false", "true" }, eventEligible); + + private static DemoSignalSeed State(string name, string path, string[] values, string initial, bool eventEligible) + => new(name, path, "ST", "INT32", "Status", string.Empty, initial, DemoValueKind.Discrete, 0, 0, values, eventEligible); + + private static DemoSignalSeed Counter(string name, string path, int initial) + => new(name, path, "ST", "INT32", "Counter", string.Empty, initial.ToString(CultureInfo.InvariantCulture), DemoValueKind.Counter, initial, 0, null, false); + + private static DemoSignalSeed Analog(string name, string path, string unit, double baseValue, double variation) + => new(name, path, "MX", "FLOAT32", "Measurement", unit, FormatDemoAnalog(baseValue, unit), DemoValueKind.Analog, baseValue, variation, null, false); + + private enum DemoDeviceRole { Bcu, Ocr, LineDiff, Distance, TrafoDiff, BusbarDiff, CapBank, Coupler } + private enum DemoValueKind { Discrete, Analog, Counter } + + private sealed record DemoDeviceSpec(string Name, string Description, string IpAddress, string ModelSummary, DemoDeviceRole Role); + + private sealed record DemoSignalSeed( + string Name, + string Path, + string FunctionalConstraint, + string DataType, + string Category, + string Unit, + string InitialValue, + DemoValueKind Kind, + double BaseValue, + double Variation, + string[]? DiscreteValues, + bool EventEligible); + + private sealed record DemoPointState( + Iec61850MonitorDevice Device, + SignalDefinition Signal, + Iec61850MonitorPoint Point, + DemoSignalSeed Seed); + + private sealed record DemoGooseStreamSpec( + string StreamKey, + string IedName, + string AppId, + string SourceMac, + string DestinationMac, + int Vlan, + string ControlBlock, + string DataSet, + IReadOnlyList Leaves); + + private sealed class DemoGooseLeafState + { + public DemoGooseLeafState(string name, string path, string typeText, string value) + { + Name = name; + Path = path; + TypeText = typeText; + Value = value; + PreviousValue = value; + } + + public string Name { get; } + public string Path { get; } + public string TypeText { get; } + public string Value { get; set; } + public string PreviousValue { get; set; } + } + + private sealed class DemoGooseStreamState + { + public DemoGooseStreamState(DemoGooseStreamSpec spec, int stateNumber, int sequenceNumber, long packetCount) + { + Spec = spec; + StateNumber = stateNumber; + SequenceNumber = sequenceNumber; + PacketCount = packetCount; + Leaves = spec.Leaves.Select(leaf => new DemoGooseLeafState(leaf.Name, leaf.Path, leaf.TypeText, leaf.Value)).ToList(); + LastTimelineTimestamp = DateTimeOffset.Now.AddSeconds(-8); + } + + public DemoGooseStreamSpec Spec { get; } + public GooseStreamRow Row { get; set; } = null!; + public List Leaves { get; } + public int StateNumber { get; set; } + public int SequenceNumber { get; set; } + public long PacketCount { get; set; } + public DateTimeOffset LastTimelineTimestamp { get; set; } + } +} diff --git a/MainWindow.xaml b/MainWindow.xaml index c611a8f29..4a0211ba3 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -5,7 +5,7 @@ Height="900" Width="1480" MinHeight="760" MinWidth="1180" WindowStartupLocation="CenterScreen" Background="{StaticResource AppBackgroundGradient}" FontFamily="Aptos, Segoe UI Variable Text, Segoe UI, Calibri" - Icon="Assets/app-icon.ico" Closing="Window_Closing"> + Icon="Assets/app-icon.ico" Closing="Window_Closing" PreviewKeyDown="MainWindow_PreviewKeyDown"> @@ -63,6 +63,11 @@ + + + diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index e1085d89a..bf831cc0d 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -93,11 +93,11 @@ public Iec61850MonitorDevice? SelectedDevice } } - public string HeaderStatusText => $"{Devices.Count} IED • {_runtime.MonitoringDeviceCount} monitoring"; + public string HeaderStatusText => $"{Devices.Count} IED • {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring"; public string DeviceCountText => $"{Devices.Count} device(s)"; - public string RuntimeSummaryText => $"Connected {_runtime.ConnectedDeviceCount} • Monitoring {_runtime.MonitoringDeviceCount} • Values {GlobalPoints.Count} • Events {Events.Count}"; - public string ConnectionInsightText => $"{_runtime.ConnectedDeviceCount} connected / {Devices.Count} discovered"; - public string MonitoringInsightText => $"{_runtime.MonitoringDeviceCount} monitoring / {GlobalPoints.Count} values"; + public string RuntimeSummaryText => $"Connected {(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} • Monitoring {(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} • Values {GlobalPoints.Count} • Events {Events.Count}"; + public string ConnectionInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsConnected) : _runtime.ConnectedDeviceCount)} connected / {Devices.Count} discovered"; + public string MonitoringInsightText => $"{(IsDemoMode ? Devices.Count(device => device.IsMonitoring) : _runtime.MonitoringDeviceCount)} monitoring / {GlobalPoints.Count} values"; public string EventInsightText => $"{Events.Count} event(s)"; public Visibility DiagnosticsAlertVisibility => _hasUnreadDiagnosticError ? Visibility.Visible : Visibility.Collapsed; public Visibility EmptyExplorerVisibility => SelectedDevice == null ? Visibility.Visible : Visibility.Collapsed; diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index 9e1f6879b..91e37c5d2 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -18,6 +18,7 @@ public sealed class Iec61850MonitorDevice : ObservableObject private bool _isMonitoring; private bool _isActive; private bool _hasReportStream; + private bool _isDemo; private bool _reportPulseActive; private string _busyStage = "Preparing IEC 61850 session…"; private double _discoveryProgressPercent; @@ -289,6 +290,16 @@ public bool HasReportStream } } + public bool IsDemo + { + get => _isDemo; + set + { + if (Set(ref _isDemo, value)) + RefreshComputed(); + } + } + public bool ReportPulseActive { get => _reportPulseActive; @@ -391,14 +402,14 @@ public void MarkDiscoveryFailed(string message) public int SelectedControlSignalCount => _selectedControlSignalCount; public bool IsBulkSignalSelectionUpdate => _isBulkSignalSelectionUpdate; public int PointCount => Points.Count; - public bool IsActionEnabled => !IsBusy; - public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring; - public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy; - public bool CanStartOrStopMonitor => !IsBusy && (IsMonitoring || SelectedLiveSignalCount > 0); - public bool CanPlayAction => !IsBusy && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); - public bool CanStopAction => !IsBusy && (IsConnected || IsMonitoring); - public bool CanRescan => !IsBusy && !IsMonitoring; - public bool CanSaveScl => !IsBusy && (SclWorkspace != null || LiveDiscoveryModel != null); + public bool IsActionEnabled => !IsBusy && !IsDemo; + public bool CanEditSignals => SignalCount > 0 && !IsBusy && !IsMonitoring && !IsDemo; + public bool CanStartMonitor => IsConnected && SelectedLiveSignalCount > 0 && !IsBusy && !IsDemo; + public bool CanStartOrStopMonitor => !IsBusy && !IsDemo && (IsMonitoring || SelectedLiveSignalCount > 0); + public bool CanPlayAction => !IsBusy && !IsDemo && (!IsConnected || (!IsMonitoring && SelectedLiveSignalCount > 0)); + public bool CanStopAction => !IsBusy && !IsDemo && (IsConnected || IsMonitoring); + public bool CanRescan => !IsBusy && !IsMonitoring && !IsDemo; + public bool CanSaveScl => !IsBusy && !IsDemo && (SclWorkspace != null || LiveDiscoveryModel != null); public string SaveSclToolTip => SclWorkspace != null ? $"Save {Name} from the opened SCL design model" : LiveDiscoveryModel != null