diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index f05511083..9c6b3883d 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -4,25 +4,21 @@ on: pull_request: paths: - "FaultRecordWindow.ComtradeOpen.cs" + - "ComtradeWorkspaceWindow*.cs" - "ComtradeWorkspaceWindow.xaml" - - "ComtradeWorkspaceWindow.xaml.cs" - - "Controls/ComtradeWaveformView.cs" + - "Controls/Comtrade*.cs" + - "ArIED61850Tester.csproj" - "Services/ArdIrecNativeBridge.cs" + - "Services/ArdIrecLocusNativeSession.cs" + - "Services/ArdIrecEmbeddedBridgeBootstrap.cs" - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeAbsoluteViewportMath.cs" - - "Services/ComtradeRangeDecimator.cs" - - "Services/ComtradeDecimatedSeriesBuilder.cs" + - "Services/Comtrade*.cs" - "Properties/AssemblyInfo.Tests.cs" - "tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs" - - "tests/ARSAS.Tests/ArdIrecViewerLauncherTests.cs" - - "tests/ARSAS.Tests/ComtradeNavigationMathTests.cs" - - "tests/ARSAS.Tests/ComtradeTimeMathTests.cs" - - "tests/ARSAS.Tests/ComtradeAbsoluteViewportMathTests.cs" - - "tests/ARSAS.Tests/ComtradeRangeDecimatorTests.cs" - - "tests/ARSAS.Tests/ComtradeDecimatedSeriesBuilderTests.cs" - - "scripts/stage-ardirec-viewer.ps1" + - "tests/ARSAS.Tests/ArdIrecLocusNativeSessionIntegrationTests.cs" + - "tests/ARSAS.Tests/Comtrade*.cs" + - "scripts/build-ardirec-bridge.ps1" + - "scripts/publish-windows-portable.ps1" - "engines/ARDIREC.lock.json" - "docs/COMTRADE_VIEWER_INTEGRATION.md" - ".github/workflows/comtrade-viewer-integration.yml" @@ -33,9 +29,9 @@ permissions: jobs: windows-viewer-integration: - name: Build pinned ArdIrec P1 engine and smoke compatibility launch + name: Build pinned ArdIrec native bridge and enforce in-process routing runs-on: windows-latest - timeout-minutes: 35 + timeout-minutes: 25 steps: - name: Checkout ARSAS @@ -43,30 +39,37 @@ jobs: with: path: ARSAS - - name: Resolve immutable ArdIrec integration lock + - name: Resolve immutable field-tested ArdIrec bridge lock shell: powershell run: | $lockPath = ".\ARSAS\engines\ARDIREC.lock.json" $lock = Get-Content $lockPath -Raw | ConvertFrom-Json - if ($lock.schema -ne 2 -or $lock.repository -notmatch '^[^/]+/[^/]+$' -or $lock.ref -ne 'main') { - throw "ArdIrec repository/ref lock is invalid; P1 must pin merged main with schema 2." + if ($lock.schema -ne 3 -or + $lock.repository -notmatch '^[^/]+/[^/]+$' -or + [string]::IsNullOrWhiteSpace([string]$lock.ref) -or + $lock.ref -notmatch '^[A-Za-z0-9._/-]+$') { + throw "ArdIrec repository/ref lock is invalid." } if ($lock.commit -notmatch '^[0-9a-f]{40}$') { throw "ArdIrec commit lock is invalid." } - if ($lock.bridge.abi -ne 1 -or $lock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + if ($lock.bridge.abi -ne 1 -or + $lock.bridge.mode -ne 'native-only' -or + $lock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { throw "ArdIrec native bridge contract is invalid." } - if ($lock.qt.version -ne '6.8.3' -or $lock.qt.arch -ne 'win64_msvc2022_64') { - throw "ArdIrec Qt fallback lock must match the validated Windows recipe (Qt 6.8.3 / win64_msvc2022_64)." - } - if ($lock.runtime.relativeExecutable -ne 'Tools/ArdIrec/ardirec.exe' -or - $lock.runtime.launchArgument -ne '--arsas-open') { - throw "ArdIrec compatibility runtime contract is invalid." + + $required = @('cursor_measurement','channel_semantics','value_representation','status_state','digital_edge_snap','phasor','harmonics','distance_locus') + $declared = @($lock.bridge.requiredCapabilities) + foreach ($capability in $required) { + if ($declared -notcontains $capability) { + throw "ArdIrec lock is missing required field-tested capability '$capability'." + } } "ARDIREC_REPOSITORY=$($lock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARDIREC_REF=$($lock.ref)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_COMMIT=$($lock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: Checkout immutable ArdIrec revision @@ -80,69 +83,64 @@ jobs: throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." } - - name: Install Qt 6.8.3 - uses: jurplel/install-qt-action@v4 - with: - version: '6.8.3' - arch: 'win64_msvc2022_64' - cache: true - - - name: Build, test and stage ArdIrec P1 engine + - name: Build and test pinned native bridge shell: powershell run: | - $publish = Join-Path $env:RUNNER_TEMP "arsas-viewer-publish" - if (Test-Path $publish) { Remove-Item $publish -Recurse -Force } - New-Item -ItemType Directory -Path $publish -Force | Out-Null - - .\ARSAS\scripts\stage-ardirec-viewer.ps1 ` + $stage = Join-Path $env:RUNNER_TEMP "arsas-native-comtrade" + .\ARSAS\scripts\build-ardirec-bridge.ps1 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` - -PublishedDirectory $publish ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-build" + -OutputDirectory $stage ` + -BuildDirectory "$env:RUNNER_TEMP\ardirec-native-build" - "VIEWER_PUBLISH=$publish" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $bridge = Join-Path $stage "ardirec_bridge.dll" + if (-not (Test-Path $bridge -PathType Leaf)) { + throw "Pinned ArdIrec native bridge was not staged: $bridge" + } + "NATIVE_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Verify native bridge and compatibility runtime - shell: pwsh + - name: Enforce field-tested native-only ARSAS COMTRADE routing + shell: powershell run: | - $viewer = Join-Path $env:VIEWER_PUBLISH "Tools\ArdIrec\ardirec.exe" - foreach ($relative in @( - "Tools\ArdIrec\ardirec_bridge.dll", - "Tools\ArdIrec\ardirec.exe", - "Tools\ArdIrec\Qt6Core.dll", - "Tools\ArdIrec\Qt6Gui.dll", - "Tools\ArdIrec\Qt6Qml.dll", - "Tools\ArdIrec\Qt6Quick.dll", - "Tools\ArdIrec\platforms\qwindows.dll" - )) { - $path = Join-Path $env:VIEWER_PUBLISH $relative - if (-not (Test-Path $path -PathType Leaf)) { - throw "Missing staged ArdIrec P1 runtime file: $path" - } + $openPath = ".\ARSAS\FaultRecordWindow.ComtradeOpen.cs" + $launcherPath = ".\ARSAS\Services\ArdIrecViewerLauncher.cs" + $bridgePath = ".\ARSAS\Services\ArdIrecNativeBridge.cs" + $bootstrapPath = ".\ARSAS\Services\ArdIrecEmbeddedBridgeBootstrap.cs" + $projectPath = ".\ARSAS\ArIED61850Tester.csproj" + $workspacePath = ".\ARSAS\ComtradeWorkspaceWindow.xaml" + $locusViewPath = ".\ARSAS\Controls\ComtradeLocusView.cs" + + $open = Get-Content $openPath -Raw + $launcher = Get-Content $launcherPath -Raw + $bridge = Get-Content $bridgePath -Raw + $bootstrap = Get-Content $bootstrapPath -Raw + $project = Get-Content $projectPath -Raw + $workspace = Get-Content $workspacePath -Raw + $locusView = Get-Content $locusViewPath -Raw + + if ($open -notmatch 'ArdIrecNativeBridge\.TryOpen' -or + $open -notmatch 'ComtradeWorkspaceWindow' -or + $open -match 'Process\.Start|TryLaunch\(') { + throw "Fault-record Open must route only to the in-process native COMTRADE workspace." + } + + if ($launcher -match 'Process\.Start|ProcessStartInfo|ardirec\.exe|ARSAS_ARDIREC_PATH|ARDIREC_VIEWER_PATH|TryLaunch\(') { + throw "External ArdIrec process-launch compatibility code is not allowed." + } + + if ($bridge -notmatch 'ardirec_bridge\.dll' -or + $bridge -notmatch 'ExpectedAbiVersion = 1' -or + $bridge -notmatch 'Harmonic' -or + $bridge -notmatch 'Distance' -or + $bootstrap -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'ArIED61850Tester\.Native\.ardirec_bridge\.dll' -or + $project -notmatch 'Tools\\ArdIrec\\ardirec_bridge\.dll') { + throw "Field-tested native ArdIrec analysis/packaging contract is incomplete." } - $fixtureDirectory = Join-Path $env:RUNNER_TEMP "COMTRADE fixture üñîçødé 日本 with spaces" - New-Item -ItemType Directory -Path $fixtureDirectory -Force | Out-Null - Copy-Item ".\ArdIrec\tests\data\binary.cfg" (Join-Path $fixtureDirectory "binary.cfg") -Force - Copy-Item ".\ArdIrec\tests\data\binary.dat" (Join-Path $fixtureDirectory "binary.dat") -Force - $fixtureCfg = Join-Path $fixtureDirectory "binary.cfg" - "NATIVE_BRIDGE_PATH=$(Join-Path $env:VIEWER_PUBLISH 'Tools\ArdIrec\ardirec_bridge.dll')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "COMTRADE_FIXTURE_CFG=$fixtureCfg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - $startInfo = [System.Diagnostics.ProcessStartInfo]::new() - $startInfo.FileName = $viewer - $startInfo.UseShellExecute = $false - $startInfo.WorkingDirectory = Split-Path -Parent $viewer - $startInfo.ArgumentList.Add("--arsas-open") - $startInfo.ArgumentList.Add($fixtureCfg) - $startInfo.Environment["QT_QPA_PLATFORM"] = "offscreen" - - $process = [System.Diagnostics.Process]::Start($startInfo) - if ($null -eq $process) { throw "Could not start staged ArdIrec compatibility viewer." } - Start-Sleep -Seconds 4 - if ($process.HasExited) { - throw "ArdIrec compatibility viewer exited unexpectedly during launch smoke test (code $($process.ExitCode))." + if ($workspace -notmatch 'ComtradePhasorView' -or + $workspace -notmatch 'ComtradeHarmonicsWorkstationView' -or + $locusView -notmatch 'class\s+ComtradeLocusView') { + throw "Production COMTRADE Phasor, Harmonics or Locus analysis surface is missing." } - Stop-Process -Id $process.Id -Force - $process.WaitForExit() - Write-Host "P1 native bridge is staged; P0 compatibility launch also passed with a Unicode COMTRADE path containing spaces." + Write-Host "COMTRADE Open uses the field-tested in-process workstation with Phasor, Harmonics and Locus; no Qt/external ArdIrec process launch remains." diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index 04d534914..9b529905d 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -10,22 +10,13 @@ on: - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" - ".github/workflows/installer-windows.yml" - - ".github/workflows/release-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - - "ComtradeWorkspaceWindow.xaml" - - "ComtradeWorkspaceWindow.xaml.cs" - - "Controls/ComtradeWaveformView.cs" - - "Services/ArdIrecNativeBridge.cs" - - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeRangeDecimator.cs" - - "Services/ComtradeDecimatedSeriesBuilder.cs" - - "tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs" - - "tests/ARSAS.Tests/ComtradeNavigationMathTests.cs" - - "tests/ARSAS.Tests/ComtradeTimeMathTests.cs" - - "tests/ARSAS.Tests/ComtradeRangeDecimatorTests.cs" - - "tests/ARSAS.Tests/ComtradeDecimatedSeriesBuilderTests.cs" + - "ComtradeWorkspaceWindow*" + - "Controls/Comtrade*" + - "Services/ArdIrec*" + - "Services/Comtrade*" + - "tests/ARSAS.Tests/ArdIrec*Tests.cs" + - "tests/ARSAS.Tests/Comtrade*Tests.cs" - "ArIED61850Tester.csproj" - "Directory.Build.props" - "VERSION" @@ -39,22 +30,13 @@ on: - "scripts/publish-windows-portable.ps1" - "scripts/stage-ardirec-viewer.ps1" - ".github/workflows/installer-windows.yml" - - ".github/workflows/release-windows.yml" - "FaultRecordWindow.ComtradeOpen.cs" - - "ComtradeWorkspaceWindow.xaml" - - "ComtradeWorkspaceWindow.xaml.cs" - - "Controls/ComtradeWaveformView.cs" - - "Services/ArdIrecNativeBridge.cs" - - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeRangeDecimator.cs" - - "Services/ComtradeDecimatedSeriesBuilder.cs" - - "tests/ARSAS.Tests/ArdIrecNativeBridgeIntegrationTests.cs" - - "tests/ARSAS.Tests/ComtradeNavigationMathTests.cs" - - "tests/ARSAS.Tests/ComtradeTimeMathTests.cs" - - "tests/ARSAS.Tests/ComtradeRangeDecimatorTests.cs" - - "tests/ARSAS.Tests/ComtradeDecimatedSeriesBuilderTests.cs" + - "ComtradeWorkspaceWindow*" + - "Controls/Comtrade*" + - "Services/ArdIrec*" + - "Services/Comtrade*" + - "tests/ARSAS.Tests/ArdIrec*Tests.cs" + - "tests/ARSAS.Tests/Comtrade*Tests.cs" - "ArIED61850Tester.csproj" - "Directory.Build.props" - "VERSION" @@ -67,8 +49,9 @@ permissions: jobs: installer: - name: Build and install/uninstall smoke test + name: Build install and field-runtime smoke test runs-on: windows-latest + timeout-minutes: 45 steps: - name: Checkout ARSAS application @@ -85,30 +68,24 @@ jobs: $projectVersion = [string]$project.Project.PropertyGroup.Version $versionFile = (Get-Content ".\ArIED61850Tester\VERSION" -Raw).Trim() if ($version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$' -or - $projectVersion -ne $version -or - $versionFile -ne $version) { + $projectVersion -ne $version -or $versionFile -ne $version) { throw "ARSAS version metadata is inconsistent: props=$version project=$projectVersion VERSION=$versionFile" } $iecLock = Get-Content ".\ArIED61850Tester\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json if ($iecLock.repository -notmatch '^[^/]+/[^/]+$' -or - $iecLock.ref -ne 'main' -or - $iecLock.commit -notmatch '^[0-9a-f]{40}$') { + $iecLock.ref -ne 'main' -or $iecLock.commit -notmatch '^[0-9a-f]{40}$') { throw "ARIEC61850 lock metadata is invalid." } $ardirecLock = Get-Content ".\ArIED61850Tester\engines\ARDIREC.lock.json" -Raw | ConvertFrom-Json - if ($ardirecLock.schema -ne 2 -or + if ($ardirecLock.schema -ne 3 -or $ardirecLock.repository -notmatch '^[^/]+/[^/]+$' -or - $ardirecLock.ref -ne 'main' -or $ardirecLock.commit -notmatch '^[0-9a-f]{40}$' -or $ardirecLock.bridge.abi -ne 1 -or $ardirecLock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or - $ardirecLock.qt.version -ne '6.8.3' -or - $ardirecLock.qt.arch -ne 'win64_msvc2022_64' -or - $ardirecLock.runtime.relativeExecutable -ne 'Tools/ArdIrec/ardirec.exe' -or - $ardirecLock.runtime.launchArgument -ne '--arsas-open') { - throw "ArdIrec P1 lock metadata is invalid." + $ardirecLock.bridge.mode -ne 'native-only') { + throw "ArdIrec P1D.5 bridge-only lock metadata is invalid." } "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append @@ -124,34 +101,23 @@ jobs: git -C .\ARIEC61850 fetch --quiet --depth 1 origin $env:ARIEC61850_COMMIT git -C .\ARIEC61850 checkout --quiet --detach $env:ARIEC61850_COMMIT $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim() - if ($actual -ne $env:ARIEC61850_COMMIT) { - throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." - } + if ($actual -ne $env:ARIEC61850_COMMIT) { throw "ARIEC61850 pin mismatch." } - - name: Checkout immutable ArdIrec P1 engine revision + - name: Checkout immutable ArdIrec engine revision shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec git -C .\ArdIrec fetch --quiet --depth 1 origin $env:ARDIREC_COMMIT git -C .\ArdIrec checkout --quiet --detach $env:ARDIREC_COMMIT $actual = (git -C .\ArdIrec rev-parse HEAD).Trim() - if ($actual -ne $env:ARDIREC_COMMIT) { - throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." - } + if ($actual -ne $env:ARDIREC_COMMIT) { throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT got $actual." } - name: Setup .NET 8 uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - - name: Install Qt 6.8.3 for COMTRADE fallback - uses: jurplel/install-qt-action@v4 - with: - version: '6.8.3' - arch: 'win64_msvc2022_64' - cache: true - - - name: Restore, build and test application solution + - name: Restore build and test application solution shell: powershell run: | dotnet restore .\ArIED61850Tester\ArIED61850Tester.sln @@ -172,7 +138,7 @@ jobs: -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" - - name: Stage pinned ArdIrec P1 native bridge and Qt fallback + - name: Stage pinned ArdIrec native analysis bridge shell: powershell run: | .\ArIED61850Tester\scripts\stage-ardirec-viewer.ps1 ` @@ -180,24 +146,19 @@ jobs: -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` -BuildDirectory "$env:RUNNER_TEMP\ardirec-installer-build" - - name: Exercise managed P1 bridge against real COMTRADE fixture + - name: Exercise managed analysis bridge before packaging shell: powershell run: | $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - - $fixtureDirectory = Join-Path $env:RUNNER_TEMP "ARSAS native COMTRADE üñîçødé 日本 with spaces" - New-Item -ItemType Directory -Path $fixtureDirectory -Force | Out-Null - Copy-Item ".\ArdIrec\tests\data\binary.cfg" (Join-Path $fixtureDirectory "binary.cfg") -Force - Copy-Item ".\ArdIrec\tests\data\binary.dat" (Join-Path $fixtureDirectory "binary.dat") -Force - $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge - $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = Join-Path $fixtureDirectory "binary.cfg" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec P1 bridge integration test failed." } + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec bridge/locus integration test failed." } - name: Install Inno Setup compiler shell: powershell @@ -212,22 +173,16 @@ jobs: -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64" ` -OutputDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist" - - name: Silent install and uninstall smoke test + - name: Silent install and field-runtime smoke test shell: powershell run: | $installerPath = ".\ArIED61850Tester\dist\ARSAS-$env:APP_VERSION-win-x64-setup.exe" if (-not (Test-Path $installerPath -PathType Leaf)) { throw "Installer not found: $installerPath" } - $installRoot = Join-Path $env:RUNNER_TEMP "ARSAS-installer-smoke" if (Test-Path $installRoot) { Remove-Item $installRoot -Recurse -Force } $install = Start-Process -FilePath $installerPath -ArgumentList @( - "/VERYSILENT", - "/SUPPRESSMSGBOXES", - "/NORESTART", - "/SP-", - "/CURRENTUSER", - "/DIR=$installRoot" + "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-", "/CURRENTUSER", "/DIR=$installRoot" ) -Wait -PassThru if ($install.ExitCode -ne 0) { throw "Installer exited with code $($install.ExitCode)." } @@ -236,7 +191,12 @@ jobs: "AR.Iec61850.Transports.Npcap.dll", "SharpPcap.dll", "PacketDotNet.dll", - "Tools\ArdIrec\ardirec_bridge.dll", + "Tools\ArdIrec\ardirec_bridge.dll" + )) { + $installedFile = Join-Path $installRoot $file + if (-not (Test-Path $installedFile -PathType Leaf)) { throw "Missing installed file: $installedFile" } + } + foreach ($removed in @( "Tools\ArdIrec\ardirec.exe", "Tools\ArdIrec\Qt6Core.dll", "Tools\ArdIrec\Qt6Gui.dll", @@ -244,16 +204,22 @@ jobs: "Tools\ArdIrec\Qt6Quick.dll", "Tools\ArdIrec\platforms\qwindows.dll" )) { - $installedFile = Join-Path $installRoot $file - if (-not (Test-Path $installedFile -PathType Leaf)) { throw "Missing installed file: $installedFile" } + $path = Join-Path $installRoot $removed + if (Test-Path $path) { throw "Removed desktop fallback is still packaged: $path" } } + $env:ARSAS_ARDIREC_BRIDGE_PATH = Join-Path $installRoot "Tools\ArdIrec\ardirec_bridge.dll" + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\minimal_1999.cfg" + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" + dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` + -c Release --no-build --no-restore ` + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { throw "Installed bridge/locus runtime validation failed." } + $uninstaller = Join-Path $installRoot "unins000.exe" if (-not (Test-Path $uninstaller -PathType Leaf)) { throw "Uninstaller was not created." } $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @( - "/VERYSILENT", - "/SUPPRESSMSGBOXES", - "/NORESTART" + "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" ) -Wait -PassThru if ($uninstall.ExitCode -ne 0) { throw "Uninstaller exited with code $($uninstall.ExitCode)." } diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index beea51f1c..3a2e1c271 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -91,17 +91,30 @@ jobs: } $ardirecLock = Get-Content ".\ArIED61850Tester\engines\ARDIREC.lock.json" -Raw | ConvertFrom-Json - if ($ardirecLock.schema -ne 2 -or + if ($ardirecLock.schema -ne 3 -or $ardirecLock.repository -notmatch '^[^/]+/[^/]+$' -or - $ardirecLock.ref -ne 'main' -or + [string]::IsNullOrWhiteSpace([string]$ardirecLock.ref) -or $ardirecLock.commit -notmatch '^[0-9a-f]{40}$' -or $ardirecLock.bridge.abi -ne 1 -or - $ardirecLock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or - $ardirecLock.qt.version -ne '6.8.3' -or - $ardirecLock.qt.arch -ne 'win64_msvc2022_64' -or - $ardirecLock.runtime.relativeExecutable -ne 'Tools/ArdIrec/ardirec.exe' -or - $ardirecLock.runtime.launchArgument -ne '--arsas-open') { - throw "ArdIrec P1 lock metadata is invalid." + $ardirecLock.bridge.mode -ne 'native-only' -or + $ardirecLock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { + throw "ArdIrec field-tested native bridge lock metadata is invalid." + } + + $requiredCapabilities = @( + 'cursor_measurement', + 'channel_semantics', + 'value_representation', + 'status_state', + 'digital_edge_snap', + 'phasor', + 'harmonics', + 'distance_locus' + ) + $lockedCapabilities = @($ardirecLock.bridge.requiredCapabilities) + $missingCapabilities = @($requiredCapabilities | Where-Object { $lockedCapabilities -notcontains $_ }) + if ($missingCapabilities.Count -gt 0) { + throw "ArdIrec release bridge is missing required analysis capabilities: $($missingCapabilities -join ', ')." } "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -126,7 +139,7 @@ jobs: throw "ARIEC61850 pin mismatch. Expected $env:ARIEC61850_COMMIT, got $actual." } - - name: Checkout immutable ArdIrec P1 engine revision + - name: Checkout immutable ArdIrec native engine revision shell: powershell run: | git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARDIREC_REPOSITORY.git" ArdIrec @@ -147,13 +160,6 @@ jobs: with: python-version: "3.12" - - name: Install Qt 6.8.3 for COMTRADE fallback - uses: jurplel/install-qt-action@v4 - with: - version: '6.8.3' - arch: 'win64_msvc2022_64' - cache: true - - name: Verify source and licensing boundaries shell: powershell run: .\ArIED61850Tester\scripts\verify-source-clean.ps1 @@ -181,7 +187,7 @@ jobs: path: ArIED61850Tester/TestResults/*.trx if-no-files-found: error - - name: Publish installer source folder + - name: Publish installer source folder with pinned native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -190,33 +196,29 @@ jobs: -SingleFile $false ` -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" - - name: Stage pinned ArdIrec P1 native bridge and Qt fallback - shell: powershell - run: | - .\ArIED61850Tester\scripts\stage-ardirec-viewer.ps1 ` - -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` - -PublishedDirectory "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-release-build" - - - name: Exercise managed P1 bridge before release packaging + - name: Exercise managed native bridge before release packaging shell: powershell run: | $publish = "$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64" $bridge = Join-Path $publish "Tools\ArdIrec\ardirec_bridge.dll" $fixture = "$env:GITHUB_WORKSPACE\ArIED61850Tester\tests\fixtures\comtrade\p1-release-smoke.cfg" - if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } - if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS P1 release fixture was not found: $fixture" } + $locusFixture = "$env:GITHUB_WORKSPACE\ArdIrec\tests\data\distance_p1.cfg" + if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not published: $bridge" } + if (-not (Test-Path $fixture -PathType Leaf)) { throw "ARSAS native release fixture was not found: $fixture" } + if (-not (Test-Path $locusFixture -PathType Leaf)) { throw "Pinned ArdIrec locus fixture was not found: $locusFixture" } $env:ARSAS_ARDIREC_BRIDGE_PATH = $bridge $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = $fixture + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = $locusFixture dotnet test .\ArIED61850Tester\tests\ARSAS.Tests\ARSAS.Tests.csproj ` -c Release --no-build --no-restore ` - --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests" - if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec P1 bridge integration test failed." } + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { throw "Release managed ArdIrec bridge/locus integration test failed." } - - name: Publish real portable single EXE + - name: Publish real portable single EXE with embedded native bridge shell: powershell run: | .\ArIED61850Tester\scripts\publish-windows-portable.ps1 ` @@ -225,7 +227,8 @@ jobs: -SingleFile $true ` -SelfContained $true ` -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` - -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" - name: Smoke-test real portable single EXE shell: powershell @@ -283,18 +286,26 @@ jobs: "AR.Iec61850.Transports.Npcap.dll", "SharpPcap.dll", "PacketDotNet.dll", - "Tools\ArdIrec\ardirec_bridge.dll", + "Tools\ArdIrec\ardirec_bridge.dll" + ) + foreach ($file in $requiredInstalledFiles) { + $path = Join-Path $installRoot $file + if (-not (Test-Path $path -PathType Leaf)) { + throw "Installed package is incomplete: $path" + } + } + + foreach ($forbidden in @( "Tools\ArdIrec\ardirec.exe", "Tools\ArdIrec\Qt6Core.dll", "Tools\ArdIrec\Qt6Gui.dll", "Tools\ArdIrec\Qt6Qml.dll", "Tools\ArdIrec\Qt6Quick.dll", "Tools\ArdIrec\platforms\qwindows.dll" - ) - foreach ($file in $requiredInstalledFiles) { - $path = Join-Path $installRoot $file - if (-not (Test-Path $path -PathType Leaf)) { - throw "Installed package is incomplete: $path" + )) { + $path = Join-Path $installRoot $forbidden + if (Test-Path $path) { + throw "External ArdIrec/Qt runtime must not be packaged: $path" } } @@ -385,7 +396,7 @@ jobs: $provenance.comtradeViewerCommit -ne $env:ARDIREC_COMMIT -or $provenance.comtradeBridgeAbi -ne 1 -or $provenance.comtradeBridgeRelativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll') { - throw "Release provenance does not match the tested app, IEC engine and COMTRADE P1 bridge revisions." + throw "Release provenance does not match the tested app, IEC engine and native COMTRADE bridge revisions." } - name: Attest installer artifact digest @@ -483,7 +494,6 @@ jobs: commit = $env:ARDIREC_COMMIT nativeBridgeAbi = [int]$env:ARDIREC_BRIDGE_ABI nativeBridgeRelativeLibrary = $env:ARDIREC_BRIDGE_RELATIVE_LIBRARY - relativeExecutable = "Tools/ArdIrec/ardirec.exe" } installer = [ordered]@{ name = "ARSAS-Windows-x64-Setup.exe" diff --git a/.release/windows.json b/.release/windows.json index 0a1048aa2..72b79c437 100644 --- a/.release/windows.json +++ b/.release/windows.json @@ -1,7 +1,7 @@ { - "version": "1.6.35", + "version": "1.6.36", "channel": "stable", "platform": "windows-x64", "primaryAsset": "ARSAS-Windows-x64-Setup.exe", - "publicationRequest": 23 + "publicationRequest": 24 } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c7e039532 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,256 @@ +# AGENTS.md — ARSAS Production Engineering Contract + +These rules apply to every AI/code agent working in this repository. ARSAS is professional substation-engineering software; engineering correctness, deterministic behavior, responsiveness, field robustness, and regression safety are product requirements from the first implementation. + +## 1. Prime directive + +Do not begin with a deliberately naive, disposable, prototype-only, or intentionally simplified implementation when the production architecture is already knowable. + +Design the smallest production-quality solution that satisfies the requirement without unnecessary architectural complexity. + +Priorities, in order: +1. engineering correctness and data integrity; +2. failure containment and crash resistance; +3. regression compatibility; +4. UI responsiveness and bounded latency; +5. performance and bounded memory; +6. maintainability and testability. + +Do not sacrifice existing working capability to make a new screenshot or demo pass. + +## 2. Mandatory workflow before editing + +For non-trivial bugs, features, performance work, or architectural changes, follow this sequence: + +RECONNAISSANCE -> REPRODUCE/BASELINE -> ROOT CAUSE -> INVARIANTS -> ARCHITECTURE IMPACT -> IMPLEMENT -> REGRESSION TEST -> FAILURE-PATH TEST -> PERFORMANCE CHECK -> CI/BUILD -> USER WORKFLOW VALIDATION + +Before changing code: +- locate the current implementation and all known consumers; +- identify the authoritative state/model and avoid creating a second source of truth; +- identify related tests, serialization formats, protocol mappings, view-model bindings, and lifecycle owners; +- state which existing behaviors must not change; +- determine root cause before applying a patch; +- prefer an existing abstraction over creating a parallel subsystem. + +Do not repeatedly modify code hoping one version works. If an attempt fails, stop, re-check assumptions, gather evidence, then revise the design. + +Three patches in the same subsystem for the same symptom are a signal to re-audit the root cause and architecture. + +## 3. Architecture boundaries + +Keep dependencies directional where practical: + +Presentation / XAML / View +-> Application / orchestration / use cases +-> Domain / engineering models / calculations +-> Infrastructure / device / file / network / OS adapters + +Rules: +- engineering calculations must not depend directly on UI controls; +- filesystem, network, device, database, update, export, and OS integration should remain behind explicit service/adaptor boundaries; +- avoid global mutable state; +- prefer one authoritative document/session state model; +- do not duplicate engineering data merely to simplify UI code; +- do not introduce abstractions for hypothetical future requirements without a current need. + +## 4. Defensive programming and failure containment + +Treat all external inputs as fallible: COMTRADE, SCL, packet captures, IEC 61850 responses, device/network traffic, local files, configuration, persisted state, user input, and update metadata. + +Validate before use: +- nullability and missing fields; +- bounds, lengths, indexes, and channel counts; +- numeric ranges, overflow, NaN and Infinity; +- schema/version assumptions; +- malformed, partial, truncated, stale, or inconsistent data; +- timeouts, cancellation, disconnect, and partial completion. + +For C# prefer nullable reference types, pattern matching, TryParse-style APIs, explicit guards, typed result/error models, `using`/`await using`, and cancellation tokens where appropriate. + +Do not wrap every function in a broad `try/catch`. Catch at meaningful failure boundaries. Never silently swallow failures. Recover locally when safe; otherwise propagate a structured failure to the owning layer and keep the application usable when isolation is possible. + +One malformed field record must not crash the entire application. + +## 5. Zero UI blocking + +The UI thread exists for presentation and interaction. + +Never perform synchronous long-running: +- file parsing or export; +- network/device communication; +- SCL/COMTRADE bulk processing; +- FFT/harmonic/phasor/locus calculations; +- report generation; +- database or package/update work + +on the UI thread. + +For a 60 Hz interface, ~16.7 ms is the total frame budget, not permission for an individual operation to consume 16 ms repeatedly. + +Use async I/O for I/O-bound work and background workers/tasks for CPU-bound work. User-triggered work that can outlive its screen/session must support cancellation when practical. Marshal only minimal results back to UI state. + +Never use arbitrary `Task.Delay`/timers to hide a race condition. + +## 6. Streaming, batching, backpressure + +High-frequency streams such as waveform updates, packet/event feeds, device telemetry, logging, cursor-driven analysis, or live IEC 61850 data must not trigger one expensive UI update per incoming event. + +Use bounded queues, coalescing, batching, throttling, latest-value semantics, or backpressure according to domain needs. + +Rules: +- avoid unbounded queues; +- avoid one task/thread per event; +- separate acquisition frequency from presentation frequency; +- preserve all samples only when the engineering requirement is lossless; +- otherwise prefer latest-state/coalesced rendering; +- always commit the exact final interaction value after coalesced drag/update flows. + +## 7. Large files and large datasets + +Do not eagerly load entire large engineering files into multiple duplicate in-memory structures when streaming/indexed access is practical. + +Prefer: +streaming -> chunked parse -> indexed metadata -> bounded working set -> viewport/analysis-specific access + +For very large local files, consider memory mapping when it materially improves the workload and lifetime model. + +Avoid full-record rescans for small cursor or viewport changes. + +## 8. Waveform, chart, table, and tree virtualization + +Rendering cost must scale primarily with visible information, not total dataset size. + +Large lists, trees, protocol frames, event logs, tables, and engineering grids must use virtualization/lazy loading/paging where supported. + +Dense waveform/time-series rendering must use viewport-aware LOD/downsampling before drawing. For disturbance waveforms, prefer extrema-preserving min/max envelope strategies over simple averaging so short transients and trip spikes are not hidden. + +Keep static layers (grid, axes, protection zones, base geometry) separate from high-frequency dynamic overlays (cursor, selection, hover, live markers) to avoid full-scene invalidation. + +Never regenerate a full waveform, locus, or harmonic dataset merely because a cursor moved. + +## 9. Memory and resource lifetime + +Avoid unnecessary allocations/copies in hot paths. + +Prefer reusable buffers, retained capacity, spans/views, pooled arrays only when profiling shows allocation pressure, and precomputed indexes instead of repeated scans. + +Do not add a generic object pool merely because pooling sounds faster. + +Every owned resource must have an explicit lifecycle: files, streams, sockets, timers, subscriptions, event handlers, cancellation sources, device handles, unmanaged buffers, workers, and GPU resources. + +Dispose/unsubscribe/release when ownership ends. A document reload/close must not leave callbacks pointing to destroyed state. + +## 10. IEC 61850 / device / protocol rules + +Never assume an IED, gateway, capture, or remote endpoint behaves perfectly. + +Validate declared lengths before field access. Use explicit timeouts. Handle disconnect, reconnect, cancellation, negative responses, partial responses, unsupported services, malformed frames, and stale state. + +Protocol state machines must have explicit transitions and bounded retry behavior. Unexpected frames must fail safely rather than corrupt session state. + +Device/network callbacks must not perform expensive UI work directly. + +Do not cosmetically alter protocol/engineering data to imitate another product. UI representation may be optimized, but timestamps, values, quality, sequence, trigger semantics, phasors, impedance, zones, and report facts must remain engineering-correct. + +## 11. Performance as a contract + +Performance-sensitive paths should define and preserve measurable budgets where practical: +- startup time; +- file-open latency; +- parsing throughput; +- interaction/cursor latency; +- UI frame time; +- allocation rate and working-set memory; +- report/export time; +- packet/event processing throughput; +- queue depth under burst load. + +Do not claim an optimization without evidence. Prefer algorithmic/layout improvements over speculative micro-optimizations. + +Do not add caches, worker pools, SIMD, object pooling, or complex concurrency unless the bottleneck and ownership model are understood. + +## 12. Regression prevention + +Every bug fix should add or update a regression test whenever technically practical. + +Test the exact failure mode that motivated the change, not only nearby happy paths. + +Before changing shared behavior, identify callers and persisted/public contracts. Do not change serialization, configuration, protocol mapping, default values, timing semantics, report semantics, or public APIs without compatibility analysis. + +For UI bugs, protect interaction semantics in addition to appearance. + +## 13. Change discipline + +Prefer the smallest coherent change that fixes the root cause. + +Do not: +- mix unrelated refactoring into a focused fix; +- create duplicate services/state stores because understanding the existing path is inconvenient; +- rename large areas without a compelling reason; +- add a dependency when the platform/current stack already provides the capability; +- replace a working subsystem simply because a rewrite appears easier. + +A new dependency must justify purpose, maintenance cost, binary impact, security implications, and runtime overhead. + +## 14. Exception-free hot paths, Result pattern, and asynchronous diagnostics + +Expected or recoverable failures must not use exceptions as normal control flow in performance-critical or high-frequency code. This includes COMTRADE/SCL parsing loops, IEC 61850 frame decoding, packet/event processing, waveform/harmonic/phasor calculation loops, device acquisition callbacks, and rendering-preparation hot paths. + +Prefer explicit C# failure contracts such as `TryXxx(...)`, typed `Result` / result records, discriminated status models, nullable returns only when the failure meaning is unambiguous, and structured error codes. A normal timeout, malformed field, missing sample, unsupported value, disconnected device, or parse rejection should not require stack unwinding. + +Exceptions from .NET, OS APIs, filesystem/network libraries, or third-party code may still occur. Catch them at the nearest meaningful infrastructure/application boundary, convert them into the repository's structured result/error model, preserve cancellation semantics, and keep exception handling out of inner loops. Do not catch and ignore exceptions. + +For hot-path diagnostics, never synchronously write files, console logs, telemetry, UI dialogs, JSON, or expensive formatted strings. Publish a small structured diagnostic event to a bounded asynchronous diagnostic channel/queue and let a background consumer aggregate, format, persist, or surface it. + +Diagnostic queues must be bounded and have an explicit overload policy. Deduplicate/rate-limit repeated failures and aggregate counts such as `MalformedRow x 4281` instead of enqueueing thousands of equivalent messages. A full/broken diagnostic queue must never block protocol processing, parsing, rendering, or UI responsiveness; retain counters/high-severity/latest events according to documented policy. + +The diagnostic subsystem is observational, not a correctness dependency. Logging failure must not become application failure. + +When implementing a `Result` family, keep it lightweight and consistent. Do not create multiple incompatible result abstractions in different subsystems. Error payloads should carry stable machine-readable codes/context first; human-readable formatting belongs outside the hot path. + +## 15. Definition of done + +A task is not complete because it compiles. + +Validate, as applicable: +BUILD ++ STATIC ANALYSIS ++ UNIT TESTS ++ REGRESSION TESTS ++ INTEGRATION/DETERMINISTIC FIXTURES ++ NEGATIVE/FAILURE-PATH TESTS ++ PERFORMANCE/ALLOCATION CHECK ++ RESOURCE/LIFECYCLE CHECK ++ PACKAGED STARTUP/SMOKE TEST ++ REAL USER WORKFLOW CHECK + +Use the repository PR template and existing engineering validation gates. Never claim a check was run when it was not. + +## 16. Agent completion report + +After implementation, report: +- Changed: what was modified; +- Root cause: why the previous behavior failed; +- Architecture: why this solution belongs in the existing design; +- Regression protection: tests/invariants added; +- Performance impact: measured result or why the path is not performance-sensitive; +- Validation: exact checks/commands and results; +- Remaining limitations: genuine unresolved limitations only. + +## 17. Parallel workstream coordination + +When multiple branches/threads are active in this repository, read `docs/WORKSTREAM_COORDINATION.md` before final integration or merge. A branch that was previously green is not automatically safe after `main` moves. + +Before landing a parallel workstream: +- integrate the latest `main` rather than overwriting it with a stale branch snapshot; +- preserve already accepted behavior from other workstreams unless an explicitly newer accepted requirement replaces it; +- resolve conflicts by authority and subsystem ownership, not mechanically by choosing one side; +- rerun the exact combined-head CI and any required field gates before merge. + +The combined result must preserve accepted behavior from every integrated workstream. + +## Final rule + +Think like the maintainer who must support ARSAS on real engineering data for years, not like a prototype generator trying to make today's screenshot pass. + +Understand first. Fix root causes. Preserve working behavior. Keep hot paths bounded. Validate failure modes. Measure performance when relevant. Prevent regressions before declaring done. diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj index 2daa0b18f..627b0d5a6 100644 --- a/ArIED61850Tester.csproj +++ b/ArIED61850Tester.csproj @@ -15,9 +15,9 @@ ARSAS ARSAS - IEC 61850 Engineering Workstation Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. - 1.6.35 - 1.6.35.0 - 1.6.35.0 + 1.6.36 + 1.6.36.0 + 1.6.36.0 https://github.com/masarray/arsas https://github.com/masarray/arsas git @@ -32,6 +32,8 @@ $(MSBuildProjectDirectory)\scripts\validate-ariec61850-lock.ps1 powershell pwsh + $(ARSAS_ARDIREC_BRIDGE_PATH) + false @@ -58,6 +60,19 @@ + + + + + + @@ -78,6 +93,12 @@ Text="ARIEC61850 integration lock was not found at '$(ArIec61850LockPath)'." /> + + + + diff --git a/ComtradeWorkspaceWindow.Analysis.cs b/ComtradeWorkspaceWindow.Analysis.cs new file mode 100644 index 000000000..abf52d981 --- /dev/null +++ b/ComtradeWorkspaceWindow.Analysis.cs @@ -0,0 +1,556 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private enum AnalysisMode + { + Waveform, + Phasor, + Harmonics + } + + private AnalysisMode _analysisMode = AnalysisMode.Waveform; + private double? _phasorCursorMilliseconds; + private CancellationTokenSource? _analysisLoadCts = new(); + private bool _analysisEventsAttached; + + // P1D.4 scrub scheduler: UI cursor movement is immediate, while native analysis is latest-wins. + // At most one native job is in flight and intermediate pointer positions are coalesced at the + // WPF composition cadence. This prevents the old cancel/spawn/flicker storm during scrubbing. + private bool _analysisRenderingHooked; + private bool _analysisScrubDirty; + private bool _analysisWorkerRunning; + private bool _analysisFinalRequested; + private int _analysisGeneration; + private ulong _lastRenderedPhasorFrame = ulong.MaxValue; + private HarmonicCacheKey? _lastRenderedHarmonicKey; + private readonly Dictionary _phasorFrameCache = new(); + private readonly Dictionary _harmonicFrameCache = new(); + + protected override void OnContentRendered(EventArgs e) + { + base.OnContentRendered(e); + if (_analysisEventsAttached) return; + _analysisEventsAttached = true; + SignalList.SelectionChanged += SignalList_AnalysisSelectionChanged; + Closed += AnalysisWindow_Closed; + InitializeDisturbanceWorkspace(); + ApplyAnalysisModeVisuals(); + UpdateAnalysisAvailability(); + } + + private void AnalysisWindow_Closed(object? sender, EventArgs e) + { + StopAnalysisRenderingPump(); + _analysisLoadCts?.Cancel(); + _analysisLoadCts?.Dispose(); + _analysisLoadCts = null; + } + + private void SignalList_AnalysisSelectionChanged(object sender, SelectionChangedEventArgs e) + { + UpdateAnalysisAvailability(); + if (_analysisMode == AnalysisMode.Harmonics && _activeSignal is not { IsAnalog: true }) + { + SetAnalysisMode(AnalysisMode.Waveform); + return; + } + + if (_analysisMode == AnalysisMode.Harmonics) + { + ResetAnalysisContext(); + QueueRealtimeAnalysisScrub(isFinal: true); + } + } + + // Retained for XAML/code-driven compatibility. Visible mode buttons route through the shell. + private void WaveformMode_Click(object sender, RoutedEventArgs e) => SetAnalysisMode(AnalysisMode.Waveform); + private void PhasorMode_Click(object sender, RoutedEventArgs e) => SetAnalysisMode(AnalysisMode.Phasor); + private void HarmonicsMode_Click(object sender, RoutedEventArgs e) => SetAnalysisMode(AnalysisMode.Harmonics); + + // Legacy P1D.2D buttons remain hidden in P1D.4. If invoked by automation, seed the single P + // cursor from the requested former global cursor instead of restoring dual-phasor behavior. + private void PhasorCursor1_Click(object sender, RoutedEventArgs e) + { + _phasorCursorMilliseconds = DisturbanceView.Cursor1Milliseconds ?? _phasorCursorMilliseconds; + SyncInvestigationTimeline(); + QueueRealtimeAnalysisScrub(isFinal: true); + } + + private void PhasorCursor2_Click(object sender, RoutedEventArgs e) + { + _phasorCursorMilliseconds = DisturbanceView.Cursor2Milliseconds ?? _phasorCursorMilliseconds; + SyncInvestigationTimeline(); + QueueRealtimeAnalysisScrub(isFinal: true); + } + + private void SetAnalysisMode(AnalysisMode mode) + { + if (mode == AnalysisMode.Phasor && _record.Info.AnalogCount == 0) + mode = AnalysisMode.Waveform; + if (mode == AnalysisMode.Harmonics && _activeSignal is not { IsAnalog: true }) + mode = AnalysisMode.Waveform; + + var changed = _analysisMode != mode; + _analysisMode = mode; + if (changed) + ResetAnalysisContext(); + + WaveformWorkspaceHost.Visibility = mode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + WaveformView.Visibility = Visibility.Collapsed; + PhasorView.Visibility = mode == AnalysisMode.Phasor ? Visibility.Visible : Visibility.Collapsed; + HarmonicsView.Visibility = mode == AnalysisMode.Harmonics ? Visibility.Visible : Visibility.Collapsed; + TimeNavigationPanel.Visibility = mode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + PhasorReferencePanel.Visibility = Visibility.Collapsed; + ApplyAnalysisModeVisuals(); + + if (mode == AnalysisMode.Waveform) + { + StopAnalysisRenderingPump(); + return; + } + + if (mode == AnalysisMode.Phasor) + EnsurePhasorCursor(); + else + EnsureHarmonicCursor(); + SyncInvestigationTimeline(); + + if (mode == AnalysisMode.Phasor && _lastRenderedPhasorFrame == ulong.MaxValue) + PhasorView.ShowMessage("Phasor", "Preparing native Voltage and Current phasors…"); + else if (mode == AnalysisMode.Harmonics && _lastRenderedHarmonicKey is null) + HarmonicsView.ShowMessage("Harmonics", "Preparing native harmonic spectrum…"); + + QueueRealtimeAnalysisScrub(isFinal: true); + } + + private void UpdateAnalysisAvailability() + { + var hasAnalogRecord = _record.Info.AnalogCount > 0; + var selectedAnalog = _activeSignal is { IsAnalog: true }; + PhasorModeButton.IsEnabled = hasAnalogRecord; + HarmonicsModeButton.IsEnabled = selectedAnalog; + PhasorModeButton.ToolTip = hasAnalogRecord + ? "Record-level Voltage and Current phasors with one dedicated P cursor on the shared timebase." + : "This COMTRADE record contains no analog channels."; + HarmonicsModeButton.ToolTip = selectedAnalog + ? "Native ArdIrec harmonic spectrum at the dedicated H cursor on the shared timebase." + : "Select an analog signal first. Harmonics uses one dedicated H cursor."; + PhasorCursor1Button.IsEnabled = false; + PhasorCursor2Button.IsEnabled = false; + } + + private void ApplyAnalysisModeVisuals() + { + ApplyModeButton(WaveformModeButton, _analysisMode == AnalysisMode.Waveform); + ApplyModeButton(PhasorModeButton, _analysisMode == AnalysisMode.Phasor); + ApplyModeButton(HarmonicsModeButton, _analysisMode == AnalysisMode.Harmonics); + } + + private static void ApplyModeButton(Button button, bool selected) + { + button.Foreground = new SolidColorBrush(selected ? Color.FromRgb(35, 86, 153) : Color.FromRgb(93, 111, 133)); + button.Background = new SolidColorBrush(selected ? Color.FromRgb(234, 243, 255) : Colors.White); + button.BorderBrush = new SolidColorBrush(selected ? Color.FromRgb(140, 177, 221) : Color.FromRgb(203, 216, 231)); + button.BorderThickness = new Thickness(1); + } + + /// + /// Called for every shell scrub update. The cursor itself has already moved synchronously; + /// native calculation is coalesced to one request per composition frame and latest frame wins. + /// + private void QueueRealtimeAnalysisScrub(bool isFinal) + { + if (_analysisMode == AnalysisMode.Waveform) return; + _analysisScrubDirty = true; + _analysisFinalRequested |= isFinal; + EnsureAnalysisRenderingPump(); + } + + private Task RefreshNativeAnalysisAsync() + { + QueueRealtimeAnalysisScrub(isFinal: true); + return Task.CompletedTask; + } + + private void EnsureAnalysisRenderingPump() + { + if (_analysisRenderingHooked) return; + CompositionTarget.Rendering += AnalysisCompositionFrame; + _analysisRenderingHooked = true; + } + + private void StopAnalysisRenderingPump() + { + if (!_analysisRenderingHooked) return; + CompositionTarget.Rendering -= AnalysisCompositionFrame; + _analysisRenderingHooked = false; + } + + private void AnalysisCompositionFrame(object? sender, EventArgs e) + { + if (_analysisMode == AnalysisMode.Waveform) + { + StopAnalysisRenderingPump(); + return; + } + if (_analysisWorkerRunning || !_analysisScrubDirty) + return; + + _analysisScrubDirty = false; + var isFinal = _analysisFinalRequested; + _analysisFinalRequested = false; + if (!TryCreateAnalysisRequest(isFinal, out var request)) + { + if (!_analysisScrubDirty) + StopAnalysisRenderingPump(); + return; + } + + if (!request.IsFinal && IsAlreadyRendered(request)) + { + if (!_analysisScrubDirty) + StopAnalysisRenderingPump(); + return; + } + + _analysisWorkerRunning = true; + _ = ExecuteAnalysisRequestAsync(request); + } + + private bool TryCreateAnalysisRequest(bool isFinal, out AnalysisScrubRequest request) + { + request = default; + if (_record.Info.FrameCount == 0) return false; + + if (_analysisMode == AnalysisMode.Phasor) + { + EnsurePhasorCursor(); + var cursorMs = _phasorCursorMilliseconds; + if (cursorMs is null || !TryResolveDisturbanceFrameAtMilliseconds(cursorMs.Value, out var frame)) + { + if (!TryResolveDisturbanceViewportCenterFrame(out frame)) return false; + } + request = new AnalysisScrubRequest(AnalysisMode.Phasor, frame, null, _analysisGeneration, isFinal); + return true; + } + + if (_activeSignal is not { IsAnalog: true } signal) return false; + EnsureHarmonicCursor(); + var harmonicMs = _harmonicCursorMilliseconds; + if (harmonicMs is null || !TryResolveDisturbanceFrameAtMilliseconds(harmonicMs.Value, out var harmonicFrame)) + { + if (!TryResolveDisturbanceViewportCenterFrame(out harmonicFrame)) return false; + } + request = new AnalysisScrubRequest(AnalysisMode.Harmonics, harmonicFrame, signal.Index, _analysisGeneration, isFinal); + return true; + } + + private bool IsAlreadyRendered(AnalysisScrubRequest request) + => request.Mode == AnalysisMode.Phasor + ? request.ReferenceFrame == _lastRenderedPhasorFrame + : request.ChannelIndex is { } channel && + _lastRenderedHarmonicKey == new HarmonicCacheKey(channel, request.ReferenceFrame); + + private async Task ExecuteAnalysisRequestAsync(AnalysisScrubRequest request) + { + try + { + var token = _analysisLoadCts?.Token ?? CancellationToken.None; + if (request.Mode == AnalysisMode.Phasor) + { + if (!_phasorFrameCache.TryGetValue(request.ReferenceFrame, out var phasor)) + { + phasor = await LoadPhasorWorkspaceAsync(request.ReferenceFrame, token).ConfigureAwait(true); + RememberPhasor(request.ReferenceFrame, phasor); + } + var timeMs = await TryReadReferenceTimeMillisecondsAsync(request.ReferenceFrame, token).ConfigureAwait(true); + if (!IsRequestCurrent(request)) return; + PresentPhasor(request.ReferenceFrame, timeMs, phasor); + } + else if (request.ChannelIndex is { } channel && _activeSignal is { IsAnalog: true } signal) + { + var key = new HarmonicCacheKey(channel, request.ReferenceFrame); + if (!_harmonicFrameCache.TryGetValue(key, out var spectrum)) + { + spectrum = await LoadHarmonicsAsync(signal, request.ReferenceFrame, token).ConfigureAwait(true); + RememberHarmonic(key, spectrum); + } + var timeMs = await TryReadReferenceTimeMillisecondsAsync(request.ReferenceFrame, token).ConfigureAwait(true); + if (!IsRequestCurrent(request)) return; + PresentHarmonics(signal, request.ReferenceFrame, timeMs, spectrum); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + if (IsRequestCurrent(request)) + { + if (request.Mode == AnalysisMode.Phasor && _lastRenderedPhasorFrame == ulong.MaxValue) + PhasorView.ShowMessage("Phasor analysis failed", ex.Message); + else if (request.Mode == AnalysisMode.Harmonics && _lastRenderedHarmonicKey is null) + HarmonicsView.ShowMessage("Harmonic analysis failed", ex.Message); + StatusTextBlock.Text = $"Native COMTRADE analysis failed: {ex.Message}"; + } + } + finally + { + _analysisWorkerRunning = false; + if (_analysisScrubDirty) + EnsureAnalysisRenderingPump(); + else + StopAnalysisRenderingPump(); + } + } + + private bool IsRequestCurrent(AnalysisScrubRequest request) + { + if (request.Generation != _analysisGeneration || request.Mode != _analysisMode) + return false; + return request.Mode != AnalysisMode.Harmonics || + (_activeSignal is { IsAnalog: true } signal && request.ChannelIndex == signal.Index); + } + + private void PresentPhasor(ulong referenceFrame, double? timeMs, ComtradePhasorWorkspaceResult result) + { + var referenceTimeText = FormatAnalysisReferenceTime(timeMs); + AnalysisReferenceTextBlock.Text = $"Analysis reference: P • frame {referenceFrame:N0} • {referenceTimeText}"; + if (result.VoltageVectors.Count == 0 && result.CurrentVectors.Count == 0) + { + if (_lastRenderedPhasorFrame == ulong.MaxValue) + PhasorView.ShowMessage("Phasor", "P does not contain a complete analyzable Voltage or Current cycle."); + StatusTextBlock.Text = "Native ArdIrec phasor analysis • P • no valid Voltage/Current vectors."; + return; + } + + PhasorView.ShowPhasors( + "P", + $"{referenceTimeText} • frame {referenceFrame:N0} • full-cycle DFT • RMS magnitude • common phase reference", + result.VoltageVectors, + result.CurrentVectors); + _lastRenderedPhasorFrame = referenceFrame; + StatusTextBlock.Text = $"Native ArdIrec phasor workstation • P • frame {referenceFrame:N0} • " + + $"{result.VoltageVectors.Count} voltage + {result.CurrentVectors.Count} current vector(s)"; + } + + private void PresentHarmonics( + ComtradeSignalItem signal, + ulong referenceFrame, + double? timeMs, + ComtradeHarmonicSpectrum spectrum) + { + var key = new HarmonicCacheKey(signal.Index, referenceFrame); + var referenceTimeText = FormatAnalysisReferenceTime(timeMs); + AnalysisReferenceTextBlock.Text = $"Analysis reference: H • frame {referenceFrame:N0} • {referenceTimeText}"; + if (!spectrum.Valid || spectrum.Bins.Count == 0) + { + if (_lastRenderedHarmonicKey is null) + HarmonicsView.ShowMessage("Harmonics", "H does not contain a valid full-cycle harmonic window."); + return; + } + + var metadata = _record.AnalogChannels[checked((int)signal.Index)]; + var display = new ComtradeHarmonicDisplaySpectrum( + metadata.Id, + metadata.Units, + spectrum.FundamentalRms, + spectrum.ThdPercent, + spectrum.DominantOrder, + spectrum.DominantRms, + spectrum.DominantPercent, + spectrum.EstimatedSampleRateHz, + spectrum.MaximumResolvableOrder, + spectrum.Bins.Select(bin => new ComtradeHarmonicDisplayBin( + bin.Order, bin.MagnitudeRms, bin.PercentOfFundamental, bin.AngleDegrees)).ToArray()); + HarmonicsView.ShowSpectrum( + "Harmonic spectrum", + BuildAnalysisSubtitle(metadata, referenceFrame, spectrum.Bins.Count, $"H cursor • orders H1…H{spectrum.Bins[^1].Order}"), + display); + _lastRenderedHarmonicKey = key; + StatusTextBlock.Text = $"Native ArdIrec harmonics • H • {referenceTimeText} • THD {spectrum.ThdPercent:G5}% • " + + (spectrum.DominantOrder > 1 + ? $"dominant H{spectrum.DominantOrder} {spectrum.DominantPercent:G4}%" + : "no meaningful distortion harmonic"); + } + + private string FormatAnalysisReferenceTime(double? timeMs) + { + var triggerMs = DisturbanceView.EffectiveTriggerMilliseconds ?? ResolveTriggerMilliseconds(); + if (timeMs is { } absolute && triggerMs is { } trigger) + return ComtradeDisturbanceTimelineMath.FormatRelativeTime(absolute - trigger); + return timeMs is { } value ? $"{value:G7} ms" : "time unavailable"; + } + + private void ResetAnalysisContext() + { + _analysisGeneration++; + _analysisScrubDirty = false; + _analysisFinalRequested = false; + _analysisLoadCts?.Cancel(); + _analysisLoadCts?.Dispose(); + _analysisLoadCts = new CancellationTokenSource(); + } + + private void RememberPhasor(ulong frame, ComtradePhasorWorkspaceResult result) + { + if (_phasorFrameCache.Count >= 64) _phasorFrameCache.Clear(); + _phasorFrameCache[frame] = result; + } + + private void RememberHarmonic(HarmonicCacheKey key, ComtradeHarmonicSpectrum result) + { + if (_harmonicFrameCache.Count >= 64) _harmonicFrameCache.Clear(); + _harmonicFrameCache[key] = result; + } + + private async Task TryReadReferenceTimeMillisecondsAsync(ulong referenceFrame, CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + token.ThrowIfCancellationRequested(); + var timestamp = await Task.Run(() => _record.ReadRawTimestamps(referenceFrame, 1)[0], token).ConfigureAwait(false); + return ComtradeTimeMath.ToMilliseconds(timestamp, _record.Info.TimeMultiplier); + } + finally + { + _nativeGate.Release(); + } + } + + private async Task LoadPhasorWorkspaceAsync( + ulong referenceFrame, + CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(() => + { + token.ThrowIfCancellationRequested(); + var descriptors = new List(_record.AnalogChannels.Count); + for (var index = 0; index < _record.AnalogChannels.Count; index++) + { + token.ThrowIfCancellationRequested(); + var channel = _record.AnalogChannels[index]; + var hasSemantics = _record.TryReadAnalogSemantics(checked((uint)index), out var semantics) && semantics is not null; + var fallbackPhase = NormalizePhase(channel.Phase, channel.Id); + var role = hasSemantics + ? semantics!.Role + : ResolveAnalogSection(channel.Units, null) switch + { + "Voltage" => ComtradePhasorWorkspaceMath.RoleVoltage, + "Current" => ComtradePhasorWorkspaceMath.RoleCurrent, + _ => 0 + }; + var phaseRole = hasSemantics + ? semantics!.PhaseRole + : ComtradePhasorWorkspaceMath.PhaseRoleFromCanonicalName(fallbackPhase); + descriptors.Add(new ComtradePhasorChannelDescriptor( + checked((uint)index), role, phaseRole, channel.Id, + ComtradePhasorWorkspaceMath.CanonicalPhaseName(phaseRole, fallbackPhase), + channel.Circuit, channel.Units)); + } + + var voltageChannels = ComtradePhasorWorkspaceMath.SelectRoleSet(descriptors, ComtradePhasorWorkspaceMath.RoleVoltage); + var currentChannels = ComtradePhasorWorkspaceMath.SelectRoleSet(descriptors, ComtradePhasorWorkspaceMath.RoleCurrent); + return new ComtradePhasorWorkspaceResult( + ReadPhasorVectors(voltageChannels, referenceFrame, token), + ReadPhasorVectors(currentChannels, referenceFrame, token)); + }, token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + } + + private IReadOnlyList ReadPhasorVectors( + IReadOnlyList channels, + ulong referenceFrame, + CancellationToken token) + { + var vectors = new List(channels.Count); + foreach (var channel in channels) + { + token.ThrowIfCancellationRequested(); + var phasor = _record.ReadPhasor(channel.Index, referenceFrame); + if (!phasor.Valid) continue; + vectors.Add(new ComtradePhasorVector( + channel.Label, + ComtradePhasorWorkspaceMath.CanonicalPhaseName(channel.PhaseRole, channel.Phase), + channel.Units, + phasor.MagnitudeRms, + phasor.AngleDegrees)); + } + return vectors; + } + + private async Task LoadHarmonicsAsync( + ComtradeSignalItem signal, + ulong referenceFrame, + CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(() => _record.ReadHarmonicSpectrum(signal.Index, referenceFrame, 25), token) + .ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + } + + private static string BuildAnalysisSubtitle( + ComtradeAnalogChannelInfo metadata, + ulong referenceFrame, + int itemCount, + string suffix) + { + var context = string.Join(" • ", new[] { metadata.Id, metadata.Phase, metadata.Circuit, metadata.Units } + .Where(value => !string.IsNullOrWhiteSpace(value))); + return $"{context} • reference frame {referenceFrame:N0} • {itemCount} {suffix}"; + } + + private static string NormalizePhase(string phase, string id) + { + var direct = (phase ?? string.Empty).Trim().ToUpperInvariant(); + if (direct is "A" or "L1") return "L1"; + if (direct is "B" or "L2") return "L2"; + if (direct is "C" or "L3") return "L3"; + if (direct is "N" or "E") return direct; + + var name = new string((id ?? string.Empty).ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); + if (name.Contains("L1") || name.EndsWith("AN") || name.EndsWith("IA") || name.EndsWith("VA") || name.EndsWith("UA")) return "L1"; + if (name.Contains("L2") || name.EndsWith("BN") || name.EndsWith("IB") || name.EndsWith("VB") || name.EndsWith("UB")) return "L2"; + if (name.Contains("L3") || name.EndsWith("CN") || name.EndsWith("IC") || name.EndsWith("VC") || name.EndsWith("UC")) return "L3"; + if (name.Contains("3I0") || name.Contains("3V0") || name.Contains("3U0") || name.Contains("RES") || name.Contains("NEUTRAL")) return "E"; + return "Other"; + } + + private readonly record struct AnalysisScrubRequest( + AnalysisMode Mode, + ulong ReferenceFrame, + uint? ChannelIndex, + int Generation, + bool IsFinal); + + private readonly record struct HarmonicCacheKey(uint ChannelIndex, ulong ReferenceFrame); + + private sealed record ComtradePhasorWorkspaceResult( + IReadOnlyList VoltageVectors, + IReadOnlyList CurrentVectors); +} diff --git a/ComtradeWorkspaceWindow.Disturbance.cs b/ComtradeWorkspaceWindow.Disturbance.cs new file mode 100644 index 000000000..bd203ae7b --- /dev/null +++ b/ComtradeWorkspaceWindow.Disturbance.cs @@ -0,0 +1,822 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const int MaxVisibleDisturbanceTracks = 16; + private readonly HashSet _disturbanceVisibleSignals = new(); + private CancellationTokenSource? _disturbanceLoadCts; + private CancellationTokenSource? _disturbanceCursorSnapCts; + private bool _disturbanceInitialized; + private bool _disturbanceVisibilityReady; + private bool _disturbanceCheckboxSync; + private bool _disturbanceInitialFocusApplied; + private ComtradeSourceViewport _disturbanceLoadedViewport; + private ComtradeSourceViewport _disturbanceRequestedViewport; + private uint[]? _disturbanceReferenceTimestamps; + private ulong[]? _disturbanceReferenceSourceFrames; + + private void InitializeDisturbanceWorkspace() + { + if (_disturbanceInitialized) return; + _disturbanceInitialized = true; + + WaveformView.Visibility = Visibility.Collapsed; + DisturbanceView.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + DisturbanceView.NavigationChanged += DisturbanceView_NavigationChanged; + DisturbanceView.CursorChanged += DisturbanceView_CursorChanged; + DisturbanceView.PanRequested += DisturbanceView_PanRequested; + DisturbanceView.PreviewMouseWheel += DisturbanceView_PreviewMouseWheel; + Closed += DisturbanceWindow_Closed; + + BuildDefaultVisibleSignals(); + _disturbanceVisibilityReady = true; + Dispatcher.BeginInvoke(SyncSignalVisibilityCheckboxes, DispatcherPriority.Loaded); + + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + _disturbanceRequestedViewport = full; + _ = ReloadDisturbanceAsync(full, initialLoad: true); + } + + private void DisturbanceWindow_Closed(object? sender, EventArgs e) + { + _disturbanceLoadCts?.Cancel(); + _disturbanceLoadCts?.Dispose(); + _disturbanceLoadCts = null; + _disturbanceCursorSnapCts?.Cancel(); + _disturbanceCursorSnapCts?.Dispose(); + _disturbanceCursorSnapCts = null; + DisturbanceView.NavigationChanged -= DisturbanceView_NavigationChanged; + DisturbanceView.CursorChanged -= DisturbanceView_CursorChanged; + DisturbanceView.PanRequested -= DisturbanceView_PanRequested; + DisturbanceView.PreviewMouseWheel -= DisturbanceView_PreviewMouseWheel; + } + + private void BuildDefaultVisibleSignals() + { + _disturbanceVisibleSignals.Clear(); + if (SignalList.ItemsSource is not IEnumerable signals) return; + var all = signals.ToArray(); + + var preferredAnalog = all + .Where(item => item.IsAnalog && item.Section == "Voltage") + .OrderBy(item => item.PhaseOrder) + .Take(3) + .Concat(all.Where(item => item.IsAnalog && item.Section == "Current") + .OrderBy(item => item.PhaseOrder) + .Take(3)) + .Distinct() + .ToList(); + foreach (var signal in all.Where(item => item.IsAnalog).OrderBy(item => item.SectionOrder).ThenBy(item => item.PhaseOrder)) + { + if (preferredAnalog.Count >= 6) break; + if (!preferredAnalog.Contains(signal)) preferredAnalog.Add(signal); + } + foreach (var signal in preferredAnalog.Take(6)) + _disturbanceVisibleSignals.Add(signal); + + var semanticDigital = all + .Where(item => !item.IsAnalog && ComtradeDisturbanceTimelineMath.IsUsefulProtectionDigital(item.Title)) + .Take(6) + .ToArray(); + var digitalDefaults = semanticDigital.Length > 0 + ? semanticDigital + : all.Where(item => !item.IsAnalog).Take(4).ToArray(); + foreach (var signal in digitalDefaults) + { + if (_disturbanceVisibleSignals.Count >= MaxVisibleDisturbanceTracks) break; + _disturbanceVisibleSignals.Add(signal); + } + } + + private void SignalVisibility_Loaded(object sender, RoutedEventArgs e) + { + if (!_disturbanceVisibilityReady || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + _disturbanceCheckboxSync = true; + checkBox.IsChecked = _disturbanceVisibleSignals.Contains(signal); + _disturbanceCheckboxSync = false; + } + + private async void SignalVisibility_Checked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (_disturbanceVisibleSignals.Contains(signal)) return; + if (_disturbanceVisibleSignals.Count >= MaxVisibleDisturbanceTracks) + { + _disturbanceCheckboxSync = true; + checkBox.IsChecked = false; + _disturbanceCheckboxSync = false; + StatusTextBlock.Text = $"Time Signals supports up to {MaxVisibleDisturbanceTracks} visible tracks at once. Hide another signal first."; + return; + } + _disturbanceVisibleSignals.Add(signal); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); + } + + private async void SignalVisibility_Unchecked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (!_disturbanceVisibleSignals.Remove(signal)) return; + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); + } + + private async void AutoSignals_Click(object sender, RoutedEventArgs e) + { + BuildDefaultVisibleSignals(); + SyncSignalVisibilityCheckboxes(); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); + } + + private async void ClearSignals_Click(object sender, RoutedEventArgs e) + { + _disturbanceVisibleSignals.Clear(); + SyncSignalVisibilityCheckboxes(); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + } + + private void SyncSignalVisibilityCheckboxes() + { + if (!_disturbanceVisibilityReady) return; + _disturbanceCheckboxSync = true; + try + { + foreach (var item in SignalList.Items.Cast()) + { + if (item is not ComtradeSignalItem signal) continue; + if (SignalList.ItemContainerGenerator.ContainerFromItem(item) is not ListBoxItem container) continue; + var checkBox = FindVisualChild(container); + if (checkBox is not null) + checkBox.IsChecked = _disturbanceVisibleSignals.Contains(signal); + } + } + finally + { + _disturbanceCheckboxSync = false; + } + } + + private static T? FindVisualChild(DependencyObject root) where T : DependencyObject + { + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var child = VisualTreeHelper.GetChild(root, i); + if (child is T typed) return typed; + var nested = FindVisualChild(child); + if (nested is not null) return nested; + } + return null; + } + + private async Task ReloadDisturbanceAsync( + ComtradeSourceViewport requestedViewport, + bool initialLoad, + bool preserveLocalView = false) + { + var previousView = new ComtradeTimeWindow(DisturbanceView.ViewStartMilliseconds, DisturbanceView.ViewEndMilliseconds); + var previousSourceViewport = _disturbanceLoadedViewport; + _disturbanceRequestedViewport = requestedViewport; + _disturbanceLoadCts?.Cancel(); + _disturbanceLoadCts?.Dispose(); + _disturbanceLoadCts = new CancellationTokenSource(); + var token = _disturbanceLoadCts.Token; + var selected = _disturbanceVisibleSignals + .OrderBy(item => item.IsAnalog ? 0 : 1) + .ThenBy(item => item.SectionOrder) + .ThenBy(item => item.PhaseOrder) + .ThenBy(item => item.Index) + .Take(MaxVisibleDisturbanceTracks) + .ToArray(); + + if (selected.Length == 0) + { + DisturbanceView.ShowMessage("Select signals to display."); + CursorReadoutCanvas.Children.Clear(); + _p1d5VisibleTrackOrder = Array.Empty(); + DigitalEventGrid.ItemsSource = Array.Empty(); + StatusTextBlock.Text = "No Time Signals tracks selected • use the checkboxes in Signals or choose Auto."; + NavigationTextBlock.Text = "Wheel scrolls signals • Ctrl+wheel zooms time • drag plot pans • drag C1/C2 measures"; + return; + } + + StatusTextBlock.Text = initialLoad + ? $"Building Time Signals workstation for {selected.Length} tracks…" + : $"Refreshing {selected.Length} synchronized tracks…"; + + try + { + var result = await LoadDisturbanceTracksAsync(selected, requestedViewport, token).ConfigureAwait(true); + if (token.IsCancellationRequested) return; + + _disturbanceLoadedViewport = result.SourceViewport; + _disturbanceRequestedViewport = result.SourceViewport; + _disturbanceReferenceTimestamps = result.ReferenceTimestamps; + _disturbanceReferenceSourceFrames = result.ReferenceSourceFrames; + P1D5RememberTrackOrder(result.Tracks); + var triggerMs = ResolveTriggerMilliseconds(); + DisturbanceView.ShowTracks(result.Tracks.Select(item => item.Track).ToArray(), _record.Info.TimeMultiplier, triggerMs); + + if (initialLoad && !_disturbanceInitialFocusApplied) + { + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + _disturbanceInitialFocusApplied = true; + + if (result.SourceViewport.FrameCount > ExactSignalFrameLimit && + TryBuildSourceViewportForTimeWindow( + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + out var triggerViewport) && + triggerViewport.FrameCount > 0 && + triggerViewport.FrameCount < result.SourceViewport.FrameCount) + { + StatusTextBlock.Text = "Refining trigger neighborhood from source frames…"; + await ReloadDisturbanceAsync(triggerViewport, initialLoad: false).ConfigureAwait(true); + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + return; + } + } + else if (preserveLocalView && previousSourceViewport == result.SourceViewport && previousView.SpanMilliseconds > 0) + { + DisturbanceView.SetViewWindow(previousView.StartMilliseconds, previousView.EndMilliseconds); + } + + DigitalEventGrid.ItemsSource = BuildDigitalEventRows(result.Tracks, triggerMs); + DigitalEventExpander.Visibility = result.Tracks.Any(item => item.Track.IsDigital) ? Visibility.Visible : Visibility.Collapsed; + ResetViewButton.IsEnabled = result.Tracks.Any(item => item.Track.Timestamps.Length > 1); + FullRecordButton.IsEnabled = result.Tracks.Any(item => item.Track.Timestamps.Length > 1); + var traceMode = P1D5IsRmsTrace ? "RMS" : "instantaneous"; + StatusTextBlock.Text = $"Time Signals • {result.Tracks.Count} tracks • {traceMode} • {P1D5RepresentationLabel} • {result.SourceViewport.FrameCount:N0} source frames" + + (result.SourceViewport.FrameCount <= ExactSignalFrameLimit ? " • exact samples" : " • bounded overview"); + QueueP1D5CursorMeasurements(); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + DisturbanceView.ShowMessage(ex.Message); + StatusTextBlock.Text = $"Time Signals load failed: {ex.Message}"; + ComtradeDiagnosticQueue.TryEnqueue("P1D5.TimeSignals", "TIMESIGNALS_LOAD_FAILURE", $"viewport={requestedViewport}", ex); + } + } + + private async Task LoadDisturbanceTracksAsync( + IReadOnlyList signals, + ComtradeSourceViewport requestedViewport, + CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(() => BuildDisturbanceTracks(signals, requestedViewport, token), token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + } + + private DisturbanceLoadResult BuildDisturbanceTracks( + IReadOnlyList signals, + ComtradeSourceViewport requestedViewport, + CancellationToken token) + { + var viewport = ComtradeAbsoluteViewportMath.Normalize(requestedViewport, _record.Info.FrameCount); + var loaded = new List(signals.Count); + var exact = viewport.FrameCount <= ExactSignalFrameLimit; + + if (exact) + { + var count = checked((int)viewport.FrameCount); + var timestamps = _record.ReadRawTimestamps(viewport.StartFrame, count); + var sourceFrames = BuildSequentialFrames(viewport.StartFrame, count); + foreach (var signal in signals) + { + token.ThrowIfCancellationRequested(); + if (signal.IsAnalog) + { + var metadata = _record.AnalogChannels[checked((int)signal.Index)]; + var recorded = _record.ReadAnalog(signal.Index, viewport.StartFrame, count); + var values = P1D5IsRmsTrace + ? ComtradeRmsSeriesBuilder.BuildExact( + recorded, + timestamps, + sourceFrames, + _record.Info.TimeMultiplier, + _record.Info.NominalFrequency, + P1D5DisplayScale(signal.Index), + token).Values + : P1D5ScaleInstantaneous(recorded, signal.Index); + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + BuildP1D5TrackSubtitle(metadata), + metadata.Units, + false, + values, + null, + timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), + SourceFrames: sourceFrames))); + } + else + { + var metadata = _record.StatusChannels[checked((int)signal.Index)]; + var states = _record.ReadStatus(signal.Index, viewport.StartFrame, count); + var edges = BuildExactDigitalEdges(states, timestamps, sourceFrames, metadata.NormalState); + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", + "", + true, + null, + states, + timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true), + SourceFrames: sourceFrames, + DigitalNormalState: metadata.NormalState, + DigitalEdges: edges))); + } + } + } + else + { + var source = new ArdIrecRangeSource(_record); + foreach (var signal in signals) + { + token.ThrowIfCancellationRequested(); + if (signal.IsAnalog) + { + var metadata = _record.AnalogChannels[checked((int)signal.Index)]; + if (P1D5IsRmsTrace) + { + var rms = ComtradeRmsSeriesBuilder.BuildBounded( + source, + signal.Index, + viewport.StartFrame, + viewport.FrameCount, + _record.Info.TimeMultiplier, + _record.Info.NominalFrequency, + P1D5DisplayScale(signal.Index), + FullRecordAnalogBuckets * 2, + cancellationToken: token); + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + BuildP1D5TrackSubtitle(metadata), + metadata.Units, + false, + rms.Values, + null, + rms.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), + PreserveAllPoints: true, + SourceFrames: rms.SourceFrames))); + } + else + { + var envelope = ComtradeRangeDecimator.BuildAnalogEnvelope( + source, + signal.Index, + viewport.StartFrame, + viewport.FrameCount, + FullRecordAnalogBuckets, + cancellationToken: token); + var series = ComtradeDecimatedSeriesBuilder.BuildAnalog(envelope); + var values = P1D5ScaleInstantaneous(series.Values, signal.Index); + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + BuildP1D5TrackSubtitle(metadata), + metadata.Units, + false, + values, + null, + series.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), + PreserveAllPoints: true, + SourceFrames: series.SourceFrames))); + } + } + else + { + var transitionSet = ComtradeRangeDecimator.BuildDigitalTransitions( + source, + signal.Index, + viewport.StartFrame, + viewport.FrameCount, + FullRecordDigitalTransitionCap, + cancellationToken: token); + var series = ComtradeDecimatedSeriesBuilder.BuildDigital(transitionSet); + var metadata = _record.StatusChannels[checked((int)signal.Index)]; + var edges = BuildReducedDigitalEdges(transitionSet, metadata.NormalState); + loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( + signal.Title, + $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", + "", + true, + null, + series.States, + series.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true), + SourceFrames: series.SourceFrames, + DigitalNormalState: metadata.NormalState, + DigitalEdges: edges, + DigitalIsLossy: series.IsTruncated))); + } + } + } + + var reference = loaded + .Select(item => item.Track) + .Where(track => track.Timestamps.Length > 0 && track.SourceFrames is { Length: > 0 }) + .OrderByDescending(track => track.Timestamps.Length) + .FirstOrDefault(); + return new DisturbanceLoadResult( + loaded, + viewport, + reference?.Timestamps ?? Array.Empty(), + reference?.SourceFrames ?? Array.Empty()); + } + + private string BuildP1D5TrackSubtitle(ComtradeAnalogChannelInfo metadata) + { + var baseSubtitle = BuildTrackSubtitle(metadata.Phase, metadata.Circuit); + var trace = P1D5IsRmsTrace ? "RMS" : "instant"; + return string.IsNullOrWhiteSpace(baseSubtitle) + ? $"{trace} • {P1D5RepresentationLabel}" + : $"{baseSubtitle} • {trace} • {P1D5RepresentationLabel}"; + } + + private static ulong[] BuildSequentialFrames(ulong startFrame, int count) + { + var frames = new ulong[count]; + for (var i = 0; i < count; i++) + frames[i] = startFrame + checked((ulong)i); + return frames; + } + + private static IReadOnlyList BuildExactDigitalEdges( + IReadOnlyList states, + IReadOnlyList timestamps, + IReadOnlyList sourceFrames, + int normalState) + { + var count = Math.Min(states.Count, Math.Min(timestamps.Count, sourceFrames.Count)); + var edges = new List(); + for (var i = 1; i < count; i++) + { + var before = states[i - 1] == 0 ? (byte)0 : (byte)1; + var after = states[i] == 0 ? (byte)0 : (byte)1; + if (before == after) continue; + edges.Add(new ComtradeDisturbanceDigitalEdge(timestamps[i], sourceFrames[i], before, after, normalState)); + } + return edges; + } + + private static IReadOnlyList BuildReducedDigitalEdges( + ComtradeDigitalTransitionSet transitionSet, + int normalState) + { + if (transitionSet.Transitions.Count <= 1) + return Array.Empty(); + return transitionSet.Transitions + .Skip(1) + .Select(transition => + { + var after = transition.State == 0 ? (byte)0 : (byte)1; + var before = after == 0 ? (byte)1 : (byte)0; + return new ComtradeDisturbanceDigitalEdge( + transition.Timestamp, + transition.Frame, + before, + after, + normalState); + }) + .ToArray(); + } + + private IReadOnlyList BuildDigitalEventRows( + IReadOnlyList tracks, + double? triggerMilliseconds) + { + var events = new List<(double Time, ulong SourceFrame, string Signal, string Event, string State)>(); + foreach (var loaded in tracks.Where(item => item.Track.IsDigital)) + { + var track = loaded.Track; + if (track.DigitalEdges is not { Count: > 0 }) continue; + foreach (var edge in track.DigitalEdges) + { + var time = ComtradeTimeMath.ToMilliseconds(edge.Timestamp, _record.Info.TimeMultiplier); + var active = (edge.AfterState != 0 ? 1 : 0) != edge.NormalState; + events.Add(( + time, + edge.SourceFrame, + track.Title, + ComtradeDisturbanceTimelineMath.DescribeDigitalEvent(track.Title, active), + $"{edge.BeforeState}→{edge.AfterState} • {(active ? "active" : "normal")}")); + } + } + + var ordered = events.OrderBy(item => item.Time).ThenBy(item => item.Signal, StringComparer.OrdinalIgnoreCase).ToArray(); + var rows = new List(ordered.Length); + double? previous = null; + foreach (var item in ordered) + { + var relative = item.Time - (triggerMilliseconds ?? 0.0); + var delta = previous is { } previousTime ? item.Time - previousTime : (double?)null; + rows.Add(new ComtradeDigitalEventRow( + item.Time, + item.SourceFrame, + ComtradeDisturbanceTimelineMath.FormatRelativeTime(relative), + delta is { } d ? $"{d:G6} ms" : "—", + item.Signal, + item.Event, + item.State)); + previous = item.Time; + } + return rows; + } + + private void DigitalEventGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (DigitalEventGrid.SelectedItem is not ComtradeDigitalEventRow row) return; + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, row.AbsoluteMilliseconds); + QueueP1D5CursorMeasurements(); + StatusTextBlock.Text = $"C1 moved to {row.Signal} • {row.Event} • {row.TimeText}."; + } + + private async void DisturbanceReset_Click(object sender, RoutedEventArgs e) + { + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + if (_disturbanceRequestedViewport != full || _disturbanceLoadedViewport != full) + { + await ReloadDisturbanceAsync(full, initialLoad: false).ConfigureAwait(true); + if (_record.Info.FrameCount > ExactSignalFrameLimit && + TryBuildSourceViewportForTimeWindow( + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + out var triggerViewport) && + triggerViewport.FrameCount > 0 && triggerViewport != full) + { + await ReloadDisturbanceAsync(triggerViewport, initialLoad: false).ConfigureAwait(true); + } + } + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + _disturbanceInitialFocusApplied = true; + QueueP1D5CursorMeasurements(); + } + + private async void DisturbanceFullRecord_Click(object sender, RoutedEventArgs e) + { + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + if (_disturbanceRequestedViewport != full || _disturbanceLoadedViewport != full) + await ReloadDisturbanceAsync(full, initialLoad: false).ConfigureAwait(true); + DisturbanceView.ResetNavigation(); + } + + private void DisturbanceView_NavigationChanged(object? sender, ComtradeDisturbanceNavigationChangedEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) return; + NavigationTextBlock.Text = e.Summary + " | wheel scrolls tracks • Ctrl+wheel zooms • drag pans"; + UpdateAnalysisAvailability(); + } + + private async void DisturbanceView_PreviewMouseWheel(object sender, MouseWheelEventArgs e) + { + if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) + return; + if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) + return; + + var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; + var plotFraction = DisturbanceView.PlotFractionAt(e.GetPosition(DisturbanceView).X); + var visibleSpan = DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds; + var anchorMilliseconds = DisturbanceView.ViewStartMilliseconds + visibleSpan * plotFraction; + var sourceFraction = plotFraction; + if (TryResolveDisturbanceFrameAtMilliseconds(anchorMilliseconds, out var anchorFrame) && current.FrameCount > 1 && + anchorFrame >= current.StartFrame && anchorFrame < current.EndExclusive) + { + sourceFraction = (anchorFrame - current.StartFrame) / (double)(current.FrameCount - 1); + } + + var target = ComtradeAbsoluteViewportMath.Zoom( + current, + _record.Info.FrameCount, + Math.Clamp(sourceFraction, 0.0, 1.0), + e.Delta > 0 ? 0.60 : 1.60, + minimumFrames: 32); + e.Handled = true; + if (target == current) return; + _disturbanceRequestedViewport = target; + await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); + } + + private async void DisturbanceView_PanRequested(object? sender, ComtradeDisturbancePanRequestedEventArgs e) + { + if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) + return; + const double epsilon = 1e-6; + if (DisturbanceView.ViewStartMilliseconds > DisturbanceView.FullStartMilliseconds + epsilon && + DisturbanceView.ViewEndMilliseconds < DisturbanceView.FullEndMilliseconds - epsilon) + return; + + var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; + var delta = ToSignedDelta(current.FrameCount, e.DeltaFraction); + var target = ComtradeAbsoluteViewportMath.Pan(current, _record.Info.FrameCount, delta); + if (target == current) return; + _disturbanceRequestedViewport = target; + await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); + } + + private async void DisturbanceView_CursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + if (!e.IsFinal || e.SnapToleranceMilliseconds <= 0 || + !_record.Supports(ArdIrecNativeBridge.CapDigitalEdgeSnap) || + !TryResolveDisturbanceFrameAtMilliseconds(e.AbsoluteMilliseconds, out var sourceFrame)) + { + if (e.IsFinal) QueueP1D5CursorMeasurements(); + return; + } + + _disturbanceCursorSnapCts?.Cancel(); + _disturbanceCursorSnapCts?.Dispose(); + _disturbanceCursorSnapCts = new CancellationTokenSource(); + var token = _disturbanceCursorSnapCts.Token; + try + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + ComtradeStatusEdge? edge; + try + { + token.ThrowIfCancellationRequested(); + var toleranceSeconds = e.SnapToleranceMilliseconds / 1000.0; + edge = await Task.Run(() => + { + _record.TryFindNearestStatusEdge(sourceFrame, toleranceSeconds, out var nativeEdge); + return nativeEdge; + }, token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + + if (token.IsCancellationRequested || edge is not { Valid: true }) + { + await Dispatcher.InvokeAsync(QueueP1D5CursorMeasurements); + return; + } + var snappedMilliseconds = ComtradeTimeMath.ToMilliseconds(edge.RawTimestamp, _record.Info.TimeMultiplier); + await Dispatcher.InvokeAsync(() => + { + DisturbanceView.SetCursorFromHost(e.Cursor, snappedMilliseconds); + QueueP1D5CursorMeasurements(); + var signal = edge.ChannelIndex < _record.StatusChannels.Count + ? _record.StatusChannels[checked((int)edge.ChannelIndex)].Id + : $"digital {edge.ChannelIndex + 1}"; + StatusTextBlock.Text = $"{(e.Cursor == ComtradeDisturbanceCursor.Cursor1 ? "C1" : "C2")} snapped to {signal} • " + + $"{edge.BeforeState}→{edge.AfterState} • {(edge.BecameActive ? "active" : "normal")}."; + }); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private ComtradeSourceViewport CurrentDisturbanceViewport() + => _disturbanceRequestedViewport.FrameCount > 0 + ? _disturbanceRequestedViewport + : ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + + private double? ResolveTriggerMilliseconds() + => ComtradeTimeMath.TryGetTriggerOffsetMilliseconds(_record.Info.StartTime, _record.Info.TriggerTime, out var trigger) + ? trigger + : null; + + private bool TryResolveDisturbanceCursorFrame(out ulong frame) + { + frame = 0; + return DisturbanceView.Cursor1Milliseconds is { } cursor && + TryResolveDisturbanceFrameAtMilliseconds(cursor, out frame); + } + + private bool TryResolveDisturbanceFrameAtMilliseconds(double milliseconds, out ulong frame) + { + frame = 0; + if (!double.IsFinite(milliseconds) || _record.Info.FrameCount == 0 || + _disturbanceReferenceTimestamps is not { Length: > 0 } timestamps || + _disturbanceReferenceSourceFrames is not { Length: > 0 } sourceFrames) + return false; + + var count = Math.Min(timestamps.Length, sourceFrames.Length); + if (count <= 0) return false; + var targetRaw = milliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var index = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(timestamps, count, targetRaw); + if (index < 0 || index >= count) return false; + frame = Math.Min(_record.Info.FrameCount - 1, sourceFrames[index]); + return true; + } + + private bool TryBuildSourceViewportForTimeWindow( + double startMilliseconds, + double endMilliseconds, + out ComtradeSourceViewport viewport) + { + viewport = default; + if (!double.IsFinite(startMilliseconds) || !double.IsFinite(endMilliseconds) || endMilliseconds <= startMilliseconds || + _disturbanceReferenceTimestamps is not { Length: > 1 } timestamps || + _disturbanceReferenceSourceFrames is not { Length: > 1 } sourceFrames) + return false; + + var count = Math.Min(timestamps.Length, sourceFrames.Length); + var targetStartRaw = startMilliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var targetEndRaw = endMilliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var startIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(timestamps, count, targetStartRaw); + var endIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(timestamps, count, targetEndRaw); + if (startIndex < 0 || endIndex < 0) return false; + + var lowIndex = Math.Max(0, Math.Min(startIndex, endIndex) - 2); + var highIndex = Math.Min(count - 1, Math.Max(startIndex, endIndex) + 2); + var startFrame = Math.Min(sourceFrames[lowIndex], sourceFrames[highIndex]); + var endFrame = Math.Max(sourceFrames[lowIndex], sourceFrames[highIndex]); + var frameCount = endFrame >= startFrame ? endFrame - startFrame + 1 : 0; + if (frameCount == 0) return false; + + const ulong minimumFrames = 32; + if (frameCount < minimumFrames) + { + var center = startFrame + frameCount / 2; + var half = minimumFrames / 2; + startFrame = center > half ? center - half : 0; + frameCount = minimumFrames; + } + + viewport = ComtradeAbsoluteViewportMath.Normalize( + new ComtradeSourceViewport(startFrame, frameCount), + _record.Info.FrameCount); + return viewport.FrameCount > 0; + } + + private bool TryResolveDisturbanceViewportCenterFrame(out ulong frame) + { + var center = DisturbanceView.ViewStartMilliseconds + + (DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds) * 0.5; + return TryResolveDisturbanceFrameAtMilliseconds(center, out frame); + } + + private static string BuildTrackSubtitle(string phase, string circuit) + => string.Join(" • ", new[] { string.IsNullOrWhiteSpace(phase) ? null : $"phase {phase}", circuit } + .Where(value => !string.IsNullOrWhiteSpace(value))); + + private static Color ResolveSignalColor(string phase, string title, bool digital) + { + if (digital) + { + var upper = (title ?? string.Empty).ToUpperInvariant(); + if (upper.Contains("TRIP")) return Color.FromRgb(220, 88, 55); + if (upper.Contains("PICK") || upper.Contains("START")) return Color.FromRgb(222, 142, 35); + if (upper.Contains("OPEN") || upper.Contains("CLOSE") || upper.Contains("CB")) return Color.FromRgb(37, 151, 102); + return Color.FromRgb(65, 139, 105); + } + + var normalized = NormalizePhase(phase, title); + return normalized switch + { + "L1" => Color.FromRgb(214, 66, 66), + "L2" => Color.FromRgb(218, 157, 0), + "L3" => Color.FromRgb(39, 118, 203), + "N" or "E" => Color.FromRgb(106, 117, 130), + _ => Color.FromRgb(48, 126, 213) + }; + } + + private sealed record LoadedDisturbanceTrack(ComtradeSignalItem Signal, ComtradeDisturbanceTrack Track); + + private sealed record DisturbanceLoadResult( + IReadOnlyList Tracks, + ComtradeSourceViewport SourceViewport, + uint[] ReferenceTimestamps, + ulong[] ReferenceSourceFrames); + + private sealed record ComtradeDigitalEventRow( + double AbsoluteMilliseconds, + ulong SourceFrame, + string TimeText, + string DeltaText, + string Signal, + string Event, + string State); +} diff --git a/ComtradeWorkspaceWindow.InvestigationShell.cs b/ComtradeWorkspaceWindow.InvestigationShell.cs new file mode 100644 index 000000000..7599e8713 --- /dev/null +++ b/ComtradeWorkspaceWindow.InvestigationShell.cs @@ -0,0 +1,340 @@ +using System.Windows; +using System.Windows.Input; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const double WaveformCursorPreviewHitRadius = 9.0; + + private bool _investigationTimelineAttached; + private double? _harmonicCursorMilliseconds; + private object? _normalizedDigitalEventSource; + private double _lastTimelineViewStartMilliseconds = double.NaN; + private double _lastTimelineViewEndMilliseconds = double.NaN; + private ComtradeInvestigationTimelineCursor? _waveformPreviewCursor; + + private void InvestigationTimeline_Loaded(object sender, RoutedEventArgs e) + { + if (_investigationTimelineAttached) return; + _investigationTimelineAttached = true; + + NormalizeP1D4ComtradeDisplayNames(); + InvestigationTimeline.CursorChanged += InvestigationTimeline_CursorChanged; + DisturbanceView.NavigationChanged += DisturbanceView_ShellNavigationChanged; + DisturbanceView.CursorChanged += DisturbanceView_ShellCursorChanged; + DisturbanceView.SizeChanged += DisturbanceView_ShellSizeChanged; + DisturbanceView.PreviewMouseDown += DisturbanceView_ShellPreviewMouseDown; + DisturbanceView.PreviewMouseMove += DisturbanceView_ShellPreviewMouseMove; + DisturbanceView.PreviewMouseUp += DisturbanceView_ShellPreviewMouseUp; + DisturbanceScrollViewer.SizeChanged += DisturbanceView_ShellSizeChanged; + SignalList.SelectionChanged += SignalList_ShellSelectionChanged; + Closed += InvestigationShell_Closed; + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + } + + private void InvestigationShell_Closed(object? sender, EventArgs e) + { + if (!_investigationTimelineAttached) return; + + StopP1D4LiveAnalysisScrub(); + InvestigationTimeline.CursorChanged -= InvestigationTimeline_CursorChanged; + DisturbanceView.NavigationChanged -= DisturbanceView_ShellNavigationChanged; + DisturbanceView.CursorChanged -= DisturbanceView_ShellCursorChanged; + DisturbanceView.SizeChanged -= DisturbanceView_ShellSizeChanged; + DisturbanceView.PreviewMouseDown -= DisturbanceView_ShellPreviewMouseDown; + DisturbanceView.PreviewMouseMove -= DisturbanceView_ShellPreviewMouseMove; + DisturbanceView.PreviewMouseUp -= DisturbanceView_ShellPreviewMouseUp; + DisturbanceScrollViewer.SizeChanged -= DisturbanceView_ShellSizeChanged; + SignalList.SelectionChanged -= SignalList_ShellSelectionChanged; + _waveformPreviewCursor = null; + _investigationTimelineAttached = false; + } + + private void TimeSignalsModeShell_Click(object sender, RoutedEventArgs e) + { + StopP1D4LiveAnalysisScrub(); + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + SetAnalysisMode(AnalysisMode.Waveform); + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + } + + private void PhasorModeShell_Click(object sender, RoutedEventArgs e) + { + EnsurePhasorCursor(); + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.PhasorCursor); + SetAnalysisMode(AnalysisMode.Phasor); + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + } + + private void HarmonicsModeShell_Click(object sender, RoutedEventArgs e) + { + if (_activeSignal is not { IsAnalog: true }) + { + var firstAnalog = ResolveP1D4HarmonicOverviewSignals().FirstOrDefault(); + if (firstAnalog is not null) + SignalList.SelectedItem = firstAnalog; + } + + EnsureHarmonicCursor(); + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.HarmonicCursor); + SetAnalysisMode(AnalysisMode.Harmonics); + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + + if (_analysisMode == AnalysisMode.Harmonics) + QueueP1D4LiveAnalysisScrub(isFinal: true); + } + + private void SignalList_ShellSelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e) + { + Dispatcher.BeginInvoke(() => + { + if (_analysisMode == AnalysisMode.Waveform && InvestigationTimeline.Mode != ComtradeInvestigationTimelineMode.DualCursor) + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + else if (_analysisMode == AnalysisMode.Phasor && InvestigationTimeline.Mode != ComtradeInvestigationTimelineMode.PhasorCursor) + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.PhasorCursor); + else if (_analysisMode == AnalysisMode.Harmonics && InvestigationTimeline.Mode != ComtradeInvestigationTimelineMode.HarmonicCursor) + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.HarmonicCursor); + SyncInvestigationTimeline(); + + if (_analysisMode == AnalysisMode.Harmonics) + QueueP1D4LiveAnalysisScrub(isFinal: true); + }, DispatcherPriority.Background); + } + + private void DisturbanceView_ShellSizeChanged(object sender, SizeChangedEventArgs e) + => SyncInvestigationTimelineGeometry(); + + private void SyncInvestigationTimelineGeometry() + { + if (!_investigationTimelineAttached) return; + InvestigationTimeline.SetPlotGeometry( + DisturbanceView.PlotLeftInset, + DisturbanceView.PlotRightInset, + Math.Max(0.0, DisturbanceView.ActualWidth)); + } + + private void DisturbanceView_ShellNavigationChanged(object? sender, ComtradeDisturbanceNavigationChangedEventArgs e) + { + var start = DisturbanceView.ViewStartMilliseconds; + var end = DisturbanceView.ViewEndMilliseconds; + var viewChanged = !NearlyEqual(start, _lastTimelineViewStartMilliseconds) || + !NearlyEqual(end, _lastTimelineViewEndMilliseconds); + if (!viewChanged) return; + + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + QueueDigitalEventTimelineNormalization(); + } + + private void DisturbanceView_ShellCursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + var shellCursor = e.Cursor == ComtradeDisturbanceCursor.Cursor1 + ? ComtradeInvestigationTimelineCursor.Cursor1 + : ComtradeInvestigationTimelineCursor.Cursor2; + InvestigationTimeline.SetCursorFromHost(shellCursor, e.AbsoluteMilliseconds); + if (e.IsFinal) + SyncInvestigationTimeline(); + } + + private void DisturbanceView_ShellPreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) return; + + var x = e.GetPosition(DisturbanceView).X; + if (e.ChangedButton == MouseButton.Right) + { + PreviewWaveformCursor(ComtradeInvestigationTimelineCursor.Cursor2, x); + return; + } + if (e.ChangedButton != MouseButton.Left) return; + + _waveformPreviewCursor = ResolveWaveformCursorAtX(x); + if (_waveformPreviewCursor is { } cursor) + PreviewWaveformCursor(cursor, x); + } + + private void DisturbanceView_ShellPreviewMouseMove(object sender, MouseEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform || _waveformPreviewCursor is not { } cursor) + return; + if (e.LeftButton != MouseButtonState.Pressed) + { + _waveformPreviewCursor = null; + return; + } + + PreviewWaveformCursor(cursor, e.GetPosition(DisturbanceView).X); + } + + private void DisturbanceView_ShellPreviewMouseUp(object sender, MouseButtonEventArgs e) + { + if (_analysisMode == AnalysisMode.Waveform && _waveformPreviewCursor is { } cursor) + PreviewWaveformCursor(cursor, e.GetPosition(DisturbanceView).X); + _waveformPreviewCursor = null; + } + + private ComtradeInvestigationTimelineCursor? ResolveWaveformCursorAtX(double x) + { + var c1Distance = DistanceToWaveformCursor(x, DisturbanceView.Cursor1Milliseconds); + var c2Distance = DistanceToWaveformCursor(x, DisturbanceView.Cursor2Milliseconds); + var c1Near = c1Distance <= WaveformCursorPreviewHitRadius; + var c2Near = c2Distance <= WaveformCursorPreviewHitRadius; + if (!c1Near && !c2Near) return null; + return c2Near && c2Distance < c1Distance + ? ComtradeInvestigationTimelineCursor.Cursor2 + : ComtradeInvestigationTimelineCursor.Cursor1; + } + + private double DistanceToWaveformCursor(double x, double? milliseconds) + { + if (milliseconds is not { } value || !double.IsFinite(value)) + return double.PositiveInfinity; + var span = DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds; + var plotWidth = WaveformPlotWidth(); + if (span <= 0 || plotWidth <= 0) + return double.PositiveInfinity; + var cursorX = DisturbanceView.PlotLeftInset + + plotWidth * (value - DisturbanceView.ViewStartMilliseconds) / span; + return Math.Abs(cursorX - x); + } + + private void PreviewWaveformCursor(ComtradeInvestigationTimelineCursor cursor, double x) + { + var span = DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds; + var plotWidth = WaveformPlotWidth(); + if (span <= 0 || plotWidth <= 0) return; + + var fraction = Math.Clamp((x - DisturbanceView.PlotLeftInset) / plotWidth, 0.0, 1.0); + var requested = DisturbanceView.ViewStartMilliseconds + span * fraction; + var tolerance = ComtradeTimeSignalsNavigationMath.SnapToleranceMilliseconds(span, plotWidth); + var snapped = DisturbanceView.SnapAnalysisCursorFromShell(requested, tolerance); + InvestigationTimeline.SetCursorFromHost(cursor, snapped); + } + + private double WaveformPlotWidth() + => Math.Max(1.0, DisturbanceView.ActualWidth - DisturbanceView.PlotLeftInset - DisturbanceView.PlotRightInset); + + private void InvestigationTimeline_CursorChanged(object? sender, ComtradeInvestigationTimelineCursorChangedEventArgs e) + { + switch (e.Cursor) + { + case ComtradeInvestigationTimelineCursor.Cursor1: + { + var actual = DisturbanceView.PlaceCursorFromShell( + ComtradeDisturbanceCursor.Cursor1, + e.AbsoluteMilliseconds, + e.SnapToleranceMilliseconds, + e.IsFinal); + InvestigationTimeline.SetCursorFromHost(ComtradeInvestigationTimelineCursor.Cursor1, actual); + break; + } + case ComtradeInvestigationTimelineCursor.Cursor2: + { + var actual = DisturbanceView.PlaceCursorFromShell( + ComtradeDisturbanceCursor.Cursor2, + e.AbsoluteMilliseconds, + e.SnapToleranceMilliseconds, + e.IsFinal); + InvestigationTimeline.SetCursorFromHost(ComtradeInvestigationTimelineCursor.Cursor2, actual); + break; + } + case ComtradeInvestigationTimelineCursor.Phasor: + { + var actual = DisturbanceView.SnapAnalysisCursorFromShell(e.AbsoluteMilliseconds, e.SnapToleranceMilliseconds); + _phasorCursorMilliseconds = actual; + InvestigationTimeline.SetCursorFromHost(ComtradeInvestigationTimelineCursor.Phasor, actual); + QueueP1D4LiveAnalysisScrub(e.IsFinal); + break; + } + case ComtradeInvestigationTimelineCursor.Harmonic: + { + var actual = DisturbanceView.SnapAnalysisCursorFromShell(e.AbsoluteMilliseconds, e.SnapToleranceMilliseconds); + _harmonicCursorMilliseconds = actual; + InvestigationTimeline.SetCursorFromHost(ComtradeInvestigationTimelineCursor.Harmonic, actual); + QueueP1D4LiveAnalysisScrub(e.IsFinal); + break; + } + } + + if (e.IsFinal) + SyncInvestigationTimeline(); + } + + private void EnsurePhasorCursor() + { + if (_phasorCursorMilliseconds.HasValue) return; + _phasorCursorMilliseconds = DisturbanceView.Cursor1Milliseconds + ?? DisturbanceView.EffectiveTriggerMilliseconds + ?? (DisturbanceView.ViewStartMilliseconds + DisturbanceView.ViewEndMilliseconds) * 0.5; + } + + private void EnsureHarmonicCursor() + { + if (_harmonicCursorMilliseconds.HasValue) return; + _harmonicCursorMilliseconds = DisturbanceView.Cursor1Milliseconds + ?? DisturbanceView.EffectiveTriggerMilliseconds + ?? (DisturbanceView.ViewStartMilliseconds + DisturbanceView.ViewEndMilliseconds) * 0.5; + } + + private void SyncInvestigationTimeline() + { + if (!_investigationTimelineAttached) return; + if (_analysisMode == AnalysisMode.Phasor) + EnsurePhasorCursor(); + if (_analysisMode == AnalysisMode.Harmonics) + EnsureHarmonicCursor(); + + _lastTimelineViewStartMilliseconds = DisturbanceView.ViewStartMilliseconds; + _lastTimelineViewEndMilliseconds = DisturbanceView.ViewEndMilliseconds; + InvestigationTimeline.SetContext( + DisturbanceView.FullStartMilliseconds, + DisturbanceView.FullEndMilliseconds, + _lastTimelineViewStartMilliseconds, + _lastTimelineViewEndMilliseconds, + DisturbanceView.EffectiveTriggerMilliseconds, + DisturbanceView.Cursor1Milliseconds, + DisturbanceView.Cursor2Milliseconds, + _phasorCursorMilliseconds, + _harmonicCursorMilliseconds); + } + + private void QueueDigitalEventTimelineNormalization() + { + Dispatcher.BeginInvoke(() => + { + var source = DigitalEventGrid.ItemsSource; + if (source is null || ReferenceEquals(source, _normalizedDigitalEventSource) || + source is not IEnumerable rows) + return; + + var trigger = DisturbanceView.EffectiveTriggerMilliseconds ?? ResolveTriggerMilliseconds(); + if (trigger is not { } triggerMilliseconds) return; + + var normalized = rows + .Select(row => row with + { + TimeText = ComtradeDisturbanceTimelineMath.FormatRelativeTime( + row.AbsoluteMilliseconds - triggerMilliseconds) + }) + .ToArray(); + _normalizedDigitalEventSource = normalized; + DigitalEventGrid.ItemsSource = normalized; + }, DispatcherPriority.Background); + } + + private static bool NearlyEqual(double left, double right) + { + if (double.IsNaN(left) || double.IsNaN(right)) return false; + if (left.Equals(right)) return true; + var scale = Math.Max(1.0, Math.Max(Math.Abs(left), Math.Abs(right))); + return Math.Abs(left - right) <= scale * 1e-10; + } +} diff --git a/ComtradeWorkspaceWindow.P1D4HarmonicsOverview.cs b/ComtradeWorkspaceWindow.P1D4HarmonicsOverview.cs new file mode 100644 index 000000000..6e0362143 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D4HarmonicsOverview.cs @@ -0,0 +1,281 @@ +using System.Runtime.CompilerServices; +using System.Text; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const int MaxP1D4HarmonicOverviewSignals = 8; + private const int P1D4HarmonicCacheCapacity = 384; + private ulong _p1d4LastRenderedHarmonicOverviewFrame = ulong.MaxValue; + private string _p1d4LastRenderedHarmonicOverviewSignature = string.Empty; + private ComtradeSignalItem[] _p1d4ResolvedHarmonicSignals = Array.Empty(); + private ulong _p1d4ResolvedHarmonicFingerprint; + private string _p1d4ResolvedHarmonicSignature = string.Empty; + private bool _p1d4ResolvedHarmonicSelectionInitialized; + private readonly BoundedFifoCache _p1d4HarmonicFrameCache = + new(P1D4HarmonicCacheCapacity); + + private IReadOnlyList ResolveP1D4HarmonicOverviewSignals() + { + if (SignalList.ItemsSource is IEnumerable source) + { + var fingerprint = 1469598103934665603UL; + var checkedCount = 0; + foreach (var item in source) + { + if (!item.IsAnalog || !_disturbanceVisibleSignals.Contains(item)) + continue; + MixP1D4HarmonicFingerprint(ref fingerprint, item); + checkedCount++; + if (checkedCount >= MaxP1D4HarmonicOverviewSignals) + break; + } + + if (checkedCount > 0) + { + fingerprint ^= (ulong)checkedCount; + fingerprint *= 1099511628211UL; + if (_p1d4ResolvedHarmonicSelectionInitialized && + _p1d4ResolvedHarmonicFingerprint == fingerprint && + _p1d4ResolvedHarmonicSignals.Length == checkedCount) + return _p1d4ResolvedHarmonicSignals; + + var resolved = new ComtradeSignalItem[checkedCount]; + var write = 0; + foreach (var item in source) + { + if (!item.IsAnalog || !_disturbanceVisibleSignals.Contains(item)) + continue; + resolved[write++] = item; + if (write >= resolved.Length) + break; + } + CacheP1D4HarmonicSelection(resolved, fingerprint); + return _p1d4ResolvedHarmonicSignals; + } + } + + if (_activeSignal is { IsAnalog: true } active) + { + var fingerprint = 1469598103934665603UL; + MixP1D4HarmonicFingerprint(ref fingerprint, active); + fingerprint ^= 1UL; + fingerprint *= 1099511628211UL; + if (!_p1d4ResolvedHarmonicSelectionInitialized || + _p1d4ResolvedHarmonicFingerprint != fingerprint || + _p1d4ResolvedHarmonicSignals.Length != 1) + CacheP1D4HarmonicSelection(new[] { active }, fingerprint); + return _p1d4ResolvedHarmonicSignals; + } + + CacheP1D4HarmonicSelection(Array.Empty(), 0); + return _p1d4ResolvedHarmonicSignals; + } + + private static void MixP1D4HarmonicFingerprint(ref ulong fingerprint, ComtradeSignalItem item) + { + fingerprint ^= item.Index; + fingerprint *= 1099511628211UL; + fingerprint ^= unchecked((uint)RuntimeHelpers.GetHashCode(item)); + fingerprint *= 1099511628211UL; + } + + private void CacheP1D4HarmonicSelection(ComtradeSignalItem[] signals, ulong fingerprint) + { + _p1d4ResolvedHarmonicSignals = signals; + _p1d4ResolvedHarmonicFingerprint = fingerprint; + _p1d4ResolvedHarmonicSignature = BuildP1D4HarmonicOverviewSignatureCore(signals); + _p1d4ResolvedHarmonicSelectionInitialized = true; + } + + private string BuildP1D4HarmonicOverviewSignature(IReadOnlyList signals) + { + if (ReferenceEquals(signals, _p1d4ResolvedHarmonicSignals)) + return _p1d4ResolvedHarmonicSignature; + return BuildP1D4HarmonicOverviewSignatureCore(signals); + } + + private static string BuildP1D4HarmonicOverviewSignatureCore(IReadOnlyList signals) + { + if (signals.Count == 0) + return string.Empty; + var builder = new StringBuilder(signals.Count * 5); + for (var index = 0; index < signals.Count; index++) + { + if (index > 0) builder.Append(','); + builder.Append(signals[index].Index.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + return builder.ToString(); + } + + private async Task> LoadP1D4HarmonicOverviewAsync( + IReadOnlyList signals, + ulong referenceFrame, + CancellationToken token) + { + var spectra = new ComtradeHarmonicSpectrum?[signals.Count]; + var missingPositions = new int[signals.Count]; + var missingSignals = new ComtradeSignalItem[signals.Count]; + var missingCount = 0; + + for (var index = 0; index < signals.Count; index++) + { + token.ThrowIfCancellationRequested(); + var key = new HarmonicCacheKey(signals[index].Index, referenceFrame); + if (_p1d4HarmonicFrameCache.TryGetValue(key, out var cached)) + { + spectra[index] = cached; + } + else + { + missingPositions[missingCount] = index; + missingSignals[missingCount] = signals[index]; + missingCount++; + } + } + + if (missingCount > 0) + { + await _nativeGate.WaitAsync(token); + try + { + var loaded = await Task.Run(() => + { + var result = new ComtradeHarmonicSpectrum[missingCount]; + for (var index = 0; index < missingCount; index++) + { + token.ThrowIfCancellationRequested(); + result[index] = _record.ReadHarmonicSpectrum( + missingSignals[index].Index, + referenceFrame, + 25); + } + return result; + }, token); + + for (var index = 0; index < missingCount; index++) + { + token.ThrowIfCancellationRequested(); + var loadedSpectrum = loaded[index]; + var position = missingPositions[index]; + var signal = missingSignals[index]; + spectra[position] = loadedSpectrum; + _p1d4HarmonicFrameCache.Set( + new HarmonicCacheKey(signal.Index, referenceFrame), + loadedSpectrum); + } + } + finally + { + _nativeGate.Release(); + } + } + + var validCount = 0; + for (var index = 0; index < spectra.Length; index++) + { + if (spectra[index] is not null) + validCount++; + } + if (validCount == 0) + return Array.Empty(); + + var entries = new P1D4HarmonicOverviewEntry[validCount]; + var write = 0; + for (var index = 0; index < signals.Count; index++) + { + if (spectra[index] is not { } spectrum) + continue; + entries[write++] = new P1D4HarmonicOverviewEntry(signals[index], spectrum); + } + return entries; + } + + private void PresentP1D4HarmonicOverview( + ulong referenceFrame, + double referenceMilliseconds, + string signature, + IReadOnlyList entries) + { + var referenceTimeText = FormatAnalysisReferenceTime(referenceMilliseconds); + AnalysisReferenceTextBlock.Text = + $"Analysis reference: H • frame {referenceFrame:N0} • {referenceTimeText}"; + + var validCount = 0; + for (var index = 0; index < entries.Count; index++) + { + var spectrum = entries[index].Spectrum; + if (spectrum.Valid && spectrum.Bins.Count > 0) + validCount++; + } + if (validCount == 0) + { + HarmonicsView.ShowMessage( + "Harmonics comparison", + "H does not contain a valid full-cycle harmonic window for the checked analog channels."); + StatusTextBlock.Text = "Native ArdIrec harmonics • H • no valid checked analog spectra."; + return; + } + + var displays = new ComtradeHarmonicOverviewSpectrum[validCount]; + ComtradeSignalItem? firstValidSignal = null; + var displayIndex = 0; + var maximumOrder = 0; + for (var entryIndex = 0; entryIndex < entries.Count; entryIndex++) + { + var entry = entries[entryIndex]; + var spectrum = entry.Spectrum; + if (!spectrum.Valid || spectrum.Bins.Count == 0) + continue; + + firstValidSignal ??= entry.Signal; + var metadata = _record.AnalogChannels[checked((int)entry.Signal.Index)]; + var bins = new ComtradeHarmonicDisplayBin[spectrum.Bins.Count]; + var binMaximum = 0; + for (var binIndex = 0; binIndex < spectrum.Bins.Count; binIndex++) + { + var bin = spectrum.Bins[binIndex]; + bins[binIndex] = new ComtradeHarmonicDisplayBin( + bin.Order, + bin.MagnitudeRms, + bin.PercentOfFundamental, + bin.AngleDegrees); + binMaximum = Math.Max(binMaximum, bin.Order); + } + + displays[displayIndex++] = new ComtradeHarmonicOverviewSpectrum( + entry.Signal.Title, + metadata.Units, + spectrum.DcComponent, + spectrum.FundamentalRms, + spectrum.ThdPercent, + spectrum.DominantOrder, + spectrum.DominantRms, + spectrum.DominantPercent, + spectrum.EstimatedSampleRateHz, + spectrum.MaximumResolvableOrder, + bins); + maximumOrder = Math.Max(maximumOrder, Math.Max(spectrum.MaximumResolvableOrder, binMaximum)); + } + maximumOrder = Math.Min(10, maximumOrder); + + HarmonicsView.ShowSpectra( + "Harmonics comparison", + $"H • {referenceTimeText} • {displays.Length} checked analog channel(s) • RMS + % fundamental • orders 0…{maximumOrder}", + displays); + + _p1d4LastRenderedHarmonicOverviewFrame = referenceFrame; + _p1d4LastRenderedHarmonicOverviewSignature = signature; + if (firstValidSignal is not null) + _lastRenderedHarmonicKey = new HarmonicCacheKey(firstValidSignal.Index, referenceFrame); + StatusTextBlock.Text = + $"Native ArdIrec harmonic comparison • H • {referenceTimeText} • {displays.Length} channel(s) • H0…H{maximumOrder}"; + } + + private readonly record struct P1D4HarmonicOverviewEntry( + ComtradeSignalItem Signal, + ComtradeHarmonicSpectrum Spectrum); +} diff --git a/ComtradeWorkspaceWindow.P1D4LiveScrub.cs b/ComtradeWorkspaceWindow.P1D4LiveScrub.cs new file mode 100644 index 000000000..6163602d0 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D4LiveScrub.cs @@ -0,0 +1,395 @@ +using System.Windows.Media; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const int P1D4PhasorCacheCapacity = 64; + + // P1D.4 field-scrub path. Visual cursor motion is synchronous and cheap; native analysis is + // coalesced at WPF composition cadence with at most one worker in flight. A final mouse-up + // revision invalidates any older in-flight result so the view cannot flash back to a stale + // phasor/harmonic frame after the user has already stopped scrubbing. + private bool _p1d4ScrubRenderingHooked; + private bool _p1d4ScrubWorkerRunning; + private bool _p1d4ScrubDirty; + private bool _p1d4FinalRequested; + private long _p1d4TargetRevision; + private long _p1d4SettledRevision; + private AnalysisMode? _p1d4OwnedAnalysisMode; + private CancellationTokenSource? _p1d4ScrubCts = new(); + private IReadOnlyList? _p1d4VoltageChannels; + private IReadOnlyList? _p1d4CurrentChannels; + private readonly BoundedFifoCache _p1d4PhasorFrameCache = + new(P1D4PhasorCacheCapacity); + + private void QueueP1D4LiveAnalysisScrub(bool isFinal) + { + if (_analysisMode == AnalysisMode.Waveform) return; + + if (_p1d4OwnedAnalysisMode != _analysisMode) + { + StopAnalysisRenderingPump(); + ResetAnalysisContext(); + _p1d4OwnedAnalysisMode = _analysisMode; + } + + unchecked { _p1d4TargetRevision++; } + if (_p1d4TargetRevision <= 0) _p1d4TargetRevision = 1; + _p1d4ScrubDirty = true; + if (isFinal) + { + _p1d4FinalRequested = true; + _p1d4SettledRevision = _p1d4TargetRevision; + } + + if (!_p1d4ScrubWorkerRunning) + EnsureP1D4LiveAnalysisRenderingPump(); + } + + private void EnsureP1D4LiveAnalysisRenderingPump() + { + if (_p1d4ScrubRenderingHooked) return; + CompositionTarget.Rendering += P1D4LiveAnalysisCompositionFrame; + _p1d4ScrubRenderingHooked = true; + } + + private void StopP1D4LiveAnalysisRenderingPump() + { + if (!_p1d4ScrubRenderingHooked) return; + CompositionTarget.Rendering -= P1D4LiveAnalysisCompositionFrame; + _p1d4ScrubRenderingHooked = false; + } + + private void StopP1D4LiveAnalysisScrub() + { + StopP1D4LiveAnalysisRenderingPump(); + _p1d4ScrubDirty = false; + _p1d4FinalRequested = false; + _p1d4OwnedAnalysisMode = null; + unchecked { _p1d4TargetRevision++; } + _p1d4SettledRevision = _p1d4TargetRevision; + _p1d4ScrubCts?.Cancel(); + _p1d4ScrubCts?.Dispose(); + _p1d4ScrubCts = null; + } + + private void P1D4LiveAnalysisCompositionFrame(object? sender, EventArgs e) + { + if (_analysisMode == AnalysisMode.Waveform) + { + StopP1D4LiveAnalysisRenderingPump(); + return; + } + if (_p1d4ScrubWorkerRunning || !_p1d4ScrubDirty) + return; + + _p1d4ScrubDirty = false; + var final = _p1d4FinalRequested; + _p1d4FinalRequested = false; + if (!TryCreateP1D4LiveAnalysisRequest(final, out var request)) + { + if (!_p1d4ScrubDirty) + StopP1D4LiveAnalysisRenderingPump(); + return; + } + + if (IsP1D4AnalysisAlreadyRendered(request)) + { + if (!_p1d4ScrubDirty) + StopP1D4LiveAnalysisRenderingPump(); + return; + } + + _p1d4ScrubWorkerRunning = true; + StopP1D4LiveAnalysisRenderingPump(); + _ = ExecuteP1D4LiveAnalysisRequestAsync(request); + } + + private bool TryCreateP1D4LiveAnalysisRequest(bool isFinal, out P1D4LiveAnalysisRequest request) + { + request = default; + if (_record.Info.FrameCount == 0) return false; + + if (_analysisMode == AnalysisMode.Phasor) + { + EnsurePhasorCursor(); + var referenceMilliseconds = _phasorCursorMilliseconds ?? + (DisturbanceView.ViewStartMilliseconds + DisturbanceView.ViewEndMilliseconds) * 0.5; + if (!TryResolveDisturbanceFrameAtMilliseconds(referenceMilliseconds, out var frame) && + !TryResolveDisturbanceViewportCenterFrame(out frame)) + return false; + + request = new P1D4LiveAnalysisRequest( + AnalysisMode.Phasor, + frame, + null, + string.Empty, + referenceMilliseconds, + _p1d4TargetRevision, + isFinal); + return true; + } + + var harmonicSignals = ResolveP1D4HarmonicOverviewSignals(); + if (harmonicSignals.Count == 0) return false; + + EnsureHarmonicCursor(); + var harmonicMilliseconds = _harmonicCursorMilliseconds ?? + (DisturbanceView.ViewStartMilliseconds + DisturbanceView.ViewEndMilliseconds) * 0.5; + if (!TryResolveDisturbanceFrameAtMilliseconds(harmonicMilliseconds, out var harmonicFrame) && + !TryResolveDisturbanceViewportCenterFrame(out harmonicFrame)) + return false; + + request = new P1D4LiveAnalysisRequest( + AnalysisMode.Harmonics, + harmonicFrame, + harmonicSignals, + BuildP1D4HarmonicOverviewSignature(harmonicSignals), + harmonicMilliseconds, + _p1d4TargetRevision, + isFinal); + return true; + } + + private bool IsP1D4AnalysisAlreadyRendered(P1D4LiveAnalysisRequest request) + => request.Mode == AnalysisMode.Phasor + ? request.ReferenceFrame == _lastRenderedPhasorFrame + : request.ReferenceFrame == _p1d4LastRenderedHarmonicOverviewFrame && + string.Equals( + request.HarmonicSignature, + _p1d4LastRenderedHarmonicOverviewSignature, + StringComparison.Ordinal); + + private async Task ExecuteP1D4LiveAnalysisRequestAsync(P1D4LiveAnalysisRequest request) + { + try + { + var outcome = await TryLoadP1D4LiveAnalysisAsync(request, EnsureP1D4ScrubToken()).ConfigureAwait(true); + if (outcome.State == P1D4LiveAnalysisState.Cancelled || !ShouldPresentP1D4LiveAnalysis(request)) + return; + + if (outcome.State == P1D4LiveAnalysisState.Failed) + { + StatusTextBlock.Text = outcome.OperatorMessage; + return; + } + + if (request.Mode == AnalysisMode.Phasor && outcome.Phasor is { } phasor) + { + PresentPhasor(request.ReferenceFrame, request.ReferenceMilliseconds, phasor); + return; + } + + if (request.Mode == AnalysisMode.Harmonics && outcome.Harmonics is { Count: > 0 } overview) + { + PresentP1D4HarmonicOverview( + request.ReferenceFrame, + request.ReferenceMilliseconds, + request.HarmonicSignature, + overview); + } + } + finally + { + _p1d4ScrubWorkerRunning = false; + if (_p1d4ScrubDirty && _analysisMode != AnalysisMode.Waveform) + EnsureP1D4LiveAnalysisRenderingPump(); + else + StopP1D4LiveAnalysisRenderingPump(); + } + } + + /// + /// Native/framework exceptions are contained at this asynchronous boundary and translated into + /// an explicit result state. Expected cancellation never reaches presentation as an error. + /// Detailed exception context is handed to the bounded diagnostic queue without blocking UI. + /// + private async Task TryLoadP1D4LiveAnalysisAsync( + P1D4LiveAnalysisRequest request, + CancellationToken token) + { + try + { + if (token.IsCancellationRequested) + return P1D4LiveAnalysisOutcome.Cancelled(); + + if (request.Mode == AnalysisMode.Phasor) + { + if (!_p1d4PhasorFrameCache.TryGetValue(request.ReferenceFrame, out var phasor)) + { + phasor = await LoadP1D4PhasorWorkspaceAsync(request.ReferenceFrame, token).ConfigureAwait(true); + if (token.IsCancellationRequested) + return P1D4LiveAnalysisOutcome.Cancelled(); + _p1d4PhasorFrameCache.Set(request.ReferenceFrame, phasor); + } + return P1D4LiveAnalysisOutcome.Success(phasor); + } + + if (request.HarmonicSignals is not { Count: > 0 } harmonicSignals) + { + return P1D4LiveAnalysisOutcome.Failed( + "HARMONIC_SELECTION_EMPTY", + "Native COMTRADE harmonics unavailable: no checked analog channel is active."); + } + + var overview = await LoadP1D4HarmonicOverviewAsync( + harmonicSignals, + request.ReferenceFrame, + token).ConfigureAwait(true); + if (token.IsCancellationRequested) + return P1D4LiveAnalysisOutcome.Cancelled(); + return P1D4LiveAnalysisOutcome.Success(overview); + } + catch (OperationCanceledException) + { + return P1D4LiveAnalysisOutcome.Cancelled(); + } + catch (ObjectDisposedException) + { + return P1D4LiveAnalysisOutcome.Cancelled(); + } + catch (Exception ex) + { + var code = request.Mode == AnalysisMode.Phasor + ? "PHASOR_NATIVE_FAILURE" + : "HARMONIC_NATIVE_FAILURE"; + ComtradeDiagnosticQueue.TryEnqueue( + "P1D4.LiveAnalysis", + code, + $"Mode={request.Mode}; frame={request.ReferenceFrame}; revision={request.Revision}; final={request.IsFinal}", + ex); + return P1D4LiveAnalysisOutcome.Failed( + code, + $"Native COMTRADE {request.Mode.ToString().ToLowerInvariant()} analysis is unavailable at this reference. Diagnostics captured."); + } + } + + private bool ShouldPresentP1D4LiveAnalysis(P1D4LiveAnalysisRequest request) + { + if (request.Mode != _analysisMode) + return false; + + if (request.Mode == AnalysisMode.Harmonics) + { + var currentSignals = ResolveP1D4HarmonicOverviewSignals(); + if (!string.Equals( + request.HarmonicSignature, + BuildP1D4HarmonicOverviewSignature(currentSignals), + StringComparison.Ordinal)) + return false; + } + + return request.Revision >= _p1d4SettledRevision; + } + + private CancellationToken EnsureP1D4ScrubToken() + { + if (_p1d4ScrubCts is null || _p1d4ScrubCts.IsCancellationRequested) + { + _p1d4ScrubCts?.Dispose(); + _p1d4ScrubCts = new CancellationTokenSource(); + } + return _p1d4ScrubCts.Token; + } + + private async Task LoadP1D4PhasorWorkspaceAsync( + ulong referenceFrame, + CancellationToken token) + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + try + { + return await Task.Run(() => + { + token.ThrowIfCancellationRequested(); + EnsureP1D4PhasorChannelSets(token); + return new ComtradePhasorWorkspaceResult( + ReadPhasorVectors(_p1d4VoltageChannels!, referenceFrame, token), + ReadPhasorVectors(_p1d4CurrentChannels!, referenceFrame, token)); + }, token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + } + + private void EnsureP1D4PhasorChannelSets(CancellationToken token) + { + if (_p1d4VoltageChannels is not null && _p1d4CurrentChannels is not null) + return; + + var descriptors = new List(_record.AnalogChannels.Count); + for (var index = 0; index < _record.AnalogChannels.Count; index++) + { + token.ThrowIfCancellationRequested(); + var channel = _record.AnalogChannels[index]; + var hasSemantics = _record.TryReadAnalogSemantics(checked((uint)index), out var semantics) && semantics is not null; + var fallbackPhase = NormalizePhase(channel.Phase, channel.Id); + var role = hasSemantics + ? semantics!.Role + : ResolveAnalogSection(channel.Units, null) switch + { + "Voltage" => ComtradePhasorWorkspaceMath.RoleVoltage, + "Current" => ComtradePhasorWorkspaceMath.RoleCurrent, + _ => 0 + }; + var phaseRole = hasSemantics + ? semantics!.PhaseRole + : ComtradePhasorWorkspaceMath.PhaseRoleFromCanonicalName(fallbackPhase); + descriptors.Add(new ComtradePhasorChannelDescriptor( + checked((uint)index), + role, + phaseRole, + channel.Id, + ComtradePhasorWorkspaceMath.CanonicalPhaseName(phaseRole, fallbackPhase), + channel.Circuit, + channel.Units)); + } + + _p1d4VoltageChannels = ComtradePhasorWorkspaceMath + .SelectRoleSet(descriptors, ComtradePhasorWorkspaceMath.RoleVoltage) + .ToArray(); + _p1d4CurrentChannels = ComtradePhasorWorkspaceMath + .SelectRoleSet(descriptors, ComtradePhasorWorkspaceMath.RoleCurrent) + .ToArray(); + } + + private enum P1D4LiveAnalysisState + { + Success, + Cancelled, + Failed + } + + private readonly record struct P1D4LiveAnalysisOutcome( + P1D4LiveAnalysisState State, + ComtradePhasorWorkspaceResult? Phasor, + IReadOnlyList? Harmonics, + string ErrorCode, + string OperatorMessage) + { + internal static P1D4LiveAnalysisOutcome Success(ComtradePhasorWorkspaceResult phasor) + => new(P1D4LiveAnalysisState.Success, phasor, null, string.Empty, string.Empty); + + internal static P1D4LiveAnalysisOutcome Success(IReadOnlyList harmonics) + => new(P1D4LiveAnalysisState.Success, null, harmonics, string.Empty, string.Empty); + + internal static P1D4LiveAnalysisOutcome Cancelled() + => new(P1D4LiveAnalysisState.Cancelled, null, null, "CANCELLED", string.Empty); + + internal static P1D4LiveAnalysisOutcome Failed(string code, string operatorMessage) + => new(P1D4LiveAnalysisState.Failed, null, null, code, operatorMessage); + } + + private readonly record struct P1D4LiveAnalysisRequest( + AnalysisMode Mode, + ulong ReferenceFrame, + IReadOnlyList? HarmonicSignals, + string HarmonicSignature, + double ReferenceMilliseconds, + long Revision, + bool IsFinal); +} diff --git a/ComtradeWorkspaceWindow.P1D4Text.cs b/ComtradeWorkspaceWindow.P1D4Text.cs new file mode 100644 index 000000000..ff8996b89 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D4Text.cs @@ -0,0 +1,56 @@ +using System.Windows.Data; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private bool _p1d4DisplayNamesNormalized; + + private void NormalizeP1D4ComtradeDisplayNames() + { + if (_p1d4DisplayNamesNormalized) return; + _p1d4DisplayNamesNormalized = true; + + if (SignalList.ItemsSource is not IEnumerable source) + return; + + var statusNames = ComtradeCfgDisplayText.TryReadStatusChannelIds( + _record.CfgPath, + _record.Info.AnalogCount, + _record.Info.StatusCount); + if (statusNames.Count == 0) + return; + + var selected = SignalList.SelectedItem as ComtradeSignalItem; + var changed = false; + var normalized = source + .Select(item => + { + if (item.IsAnalog || !statusNames.TryGetValue(item.Index, out var recovered) || + string.IsNullOrWhiteSpace(recovered) || string.Equals(recovered, item.Title, StringComparison.Ordinal)) + return item; + + changed = true; + return item with { Title = recovered }; + }) + .OrderBy(item => item.SectionOrder) + .ThenBy(item => item.PhaseOrder) + .ThenBy(item => item.Index) + .ToList(); + + if (!changed) return; + + SignalList.ItemsSource = normalized; + var view = CollectionViewSource.GetDefaultView(normalized); + view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(ComtradeSignalItem.Section))); + + if (selected is not null) + { + SignalList.SelectedItem = normalized.FirstOrDefault(item => + item.IsAnalog == selected.IsAnalog && item.Index == selected.Index); + } + if (SignalList.SelectedItem is null && normalized.Count > 0) + SignalList.SelectedIndex = 0; + } +} diff --git a/ComtradeWorkspaceWindow.P1D5CursorMeasurements.cs b/ComtradeWorkspaceWindow.P1D5CursorMeasurements.cs new file mode 100644 index 000000000..88bc42ea4 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5CursorMeasurements.cs @@ -0,0 +1,486 @@ +using System.Diagnostics; +using System.Globalization; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const double P1D5TrackTopMargin = 14.0; + private const double P1D5AnalogTrackHeight = 92.0; + private const double P1D5DigitalTrackHeight = 38.0; + private const double P1D5TrackGap = 5.0; + private static readonly long P1D5InteractiveMeasurementIntervalTicks = Math.Max(1, Stopwatch.Frequency / 12); + + [Flags] + private enum P1D5MeasurementTargets + { + None = 0, + Cursor1 = 1, + Cursor2 = 2, + Both = Cursor1 | Cursor2 + } + + private readonly Dictionary _p1d5CursorReadoutControls = new(); + private readonly Dictionary _p1d5CachedReadouts = new(); + private ComtradeSignalItem[] _p1d5VisibleTrackOrder = Array.Empty(); + private ComtradeSignalItem[] _p1d5VisibleAnalogTrackOrder = Array.Empty(); + private bool _p1d5MeasurementRenderingHooked; + private bool _p1d5MeasurementDirty; + private bool _p1d5MeasurementWorkerRunning; + private P1D5MeasurementTargets _p1d5PendingMeasurementTargets; + private P1D5MeasurementTargets _p1d5DeferredInteractiveTargets; + private P1D5MeasurementTargets _p1d5InFlightMeasurementTargets; + private long _p1d5LastInteractiveMeasurementQueueTicks; + private long _p1d5MeasurementRevision; + private long _p1d5LastPresentedMeasurementRevision; + private CancellationTokenSource? _p1d5MeasurementCts = new(); + private bool _p1d5MeasurementEventsAttached; + + private void AttachP1D5MeasurementEvents() + { + if (_p1d5MeasurementEventsAttached) + return; + _p1d5MeasurementEventsAttached = true; + + InvestigationTimeline.CursorChanged += P1D5InvestigationTimeline_CursorChanged; + DisturbanceView.CursorChanged += P1D5DisturbanceCursorChanged; + Closed += P1D5MeasurementWindow_Closed; + } + + private void P1D5MeasurementWindow_Closed(object? sender, EventArgs e) + { + StopP1D5MeasurementRenderingPump(); + _p1d5MeasurementCts?.Cancel(); + _p1d5MeasurementCts?.Dispose(); + _p1d5MeasurementCts = null; + if (_p1d5MeasurementEventsAttached) + { + InvestigationTimeline.CursorChanged -= P1D5InvestigationTimeline_CursorChanged; + DisturbanceView.CursorChanged -= P1D5DisturbanceCursorChanged; + _p1d5MeasurementEventsAttached = false; + } + } + + private void P1D5InvestigationTimeline_CursorChanged(object? sender, ComtradeInvestigationTimelineCursorChangedEventArgs e) + { + var target = e.Cursor switch + { + ComtradeInvestigationTimelineCursor.Cursor1 => P1D5MeasurementTargets.Cursor1, + ComtradeInvestigationTimelineCursor.Cursor2 => P1D5MeasurementTargets.Cursor2, + _ => P1D5MeasurementTargets.None + }; + if (target != P1D5MeasurementTargets.None) + QueueP1D5CursorMeasurements(target, e.IsFinal); + } + + private void P1D5DisturbanceCursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) + return; + QueueP1D5CursorMeasurements( + e.Cursor == ComtradeDisturbanceCursor.Cursor1 + ? P1D5MeasurementTargets.Cursor1 + : P1D5MeasurementTargets.Cursor2, + e.IsFinal); + } + + private void P1D5RememberTrackOrder(IReadOnlyList tracks) + { + var next = new ComtradeSignalItem[tracks.Count]; + var analogCount = 0; + for (var index = 0; index < tracks.Count; index++) + { + var signal = tracks[index].Signal; + next[index] = signal; + if (signal.IsAnalog) analogCount++; + } + + var analog = new ComtradeSignalItem[analogCount]; + var write = 0; + for (var index = 0; index < next.Length; index++) + { + if (next[index].IsAnalog) + analog[write++] = next[index]; + } + + _p1d5VisibleTrackOrder = next; + _p1d5VisibleAnalogTrackOrder = analog; + _p1d5CachedReadouts.Clear(); + _p1d5DeferredInteractiveTargets = P1D5MeasurementTargets.None; + RebuildP1D5CursorReadoutOverlay(); + AttachP1D5MeasurementEvents(); + QueueP1D5CursorMeasurements(); + } + + private void RebuildP1D5CursorReadoutOverlay() + { + CursorReadoutCanvas.Children.Clear(); + _p1d5CursorReadoutControls.Clear(); + var top = P1D5TrackTopMargin; + + for (var index = 0; index < _p1d5VisibleTrackOrder.Length; index++) + { + var signal = _p1d5VisibleTrackOrder[index]; + if (!signal.IsAnalog) + { + top += P1D5DigitalTrackHeight + P1D5TrackGap; + continue; + } + + var c1 = CreateP1D5CursorReadout(Color.FromRgb(205, 126, 20)); + var c2 = CreateP1D5CursorReadout(Color.FromRgb(20, 143, 183)); + c1.Text = ComtradeCursorReadoutPolicy.FormatValue("C1", P1D5IsRmsTrace, null, CultureInfo.CurrentCulture); + c2.Text = ComtradeCursorReadoutPolicy.FormatValue("C2", P1D5IsRmsTrace, null, CultureInfo.CurrentCulture); + Canvas.SetLeft(c1, 22.0); + Canvas.SetTop(c1, top + 45.0); + Canvas.SetLeft(c2, 22.0); + Canvas.SetTop(c2, top + 62.0); + CursorReadoutCanvas.Children.Add(c1); + CursorReadoutCanvas.Children.Add(c2); + _p1d5CursorReadoutControls[signal.Index] = new P1D5CursorReadoutControls(c1, c2); + top += P1D5AnalogTrackHeight + P1D5TrackGap; + } + } + + private static TextBlock CreateP1D5CursorReadout(Color color) + => new() + { + FontFamily = new FontFamily("Segoe UI"), + FontSize = 8.8, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(color), + IsHitTestVisible = false, + Text = string.Empty + }; + + private void QueueP1D5CursorMeasurements(P1D5MeasurementTargets targets, bool isFinal = true) + { + if (_analysisMode != AnalysisMode.Waveform || targets == P1D5MeasurementTargets.None || + _p1d5VisibleAnalogTrackOrder.Length == 0 || !_record.Supports(ArdIrecNativeBridge.CapCursorMeasurement)) + return; + + var now = Stopwatch.GetTimestamp(); + if (!isFinal && now - _p1d5LastInteractiveMeasurementQueueTicks < P1D5InteractiveMeasurementIntervalTicks) + { + _p1d5DeferredInteractiveTargets |= targets; + return; + } + + targets |= _p1d5DeferredInteractiveTargets; + _p1d5DeferredInteractiveTargets = P1D5MeasurementTargets.None; + _p1d5LastInteractiveMeasurementQueueTicks = now; + + // If a newer accepted cursor sample invalidates a worker already in flight, carry that + // worker's target into the replacement request. Raw pointer events between accepted samples + // never create revisions, so native work cannot churn faster than the presentation budget. + if (_p1d5MeasurementWorkerRunning) + _p1d5PendingMeasurementTargets |= _p1d5InFlightMeasurementTargets; + _p1d5PendingMeasurementTargets |= targets; + + var revision = Interlocked.Increment(ref _p1d5MeasurementRevision); + if (revision <= 0) + Interlocked.Exchange(ref _p1d5MeasurementRevision, 1); + + _p1d5MeasurementDirty = true; + if (!_p1d5MeasurementWorkerRunning) + EnsureP1D5MeasurementRenderingPump(); + } + + private void InvalidateP1D5CursorMeasurementGeneration() + { + var revision = Interlocked.Increment(ref _p1d5MeasurementRevision); + if (revision <= 0) + Interlocked.Exchange(ref _p1d5MeasurementRevision, 1); + + _p1d5PendingMeasurementTargets = P1D5MeasurementTargets.None; + _p1d5DeferredInteractiveTargets = P1D5MeasurementTargets.None; + _p1d5MeasurementDirty = false; + if (!_p1d5MeasurementWorkerRunning) + StopP1D5MeasurementRenderingPump(); + } + + private P1D5MeasurementTargets PresentP1D5CachedRepresentation(int representation) + { + var missing = P1D5MeasurementTargets.None; + for (var index = 0; index < _p1d5VisibleAnalogTrackOrder.Length; index++) + { + var signal = _p1d5VisibleAnalogTrackOrder[index]; + if (!_p1d5CursorReadoutControls.TryGetValue(signal.Index, out var controls)) + continue; + + if (_p1d5CachedReadouts.TryGetValue(signal.Index, out var cached) && cached.Cursor1 is { } c1) + controls.Cursor1.Text = FormatP1D5CachedCursorValue("C1", signal.Index, c1, representation); + else + missing |= P1D5MeasurementTargets.Cursor1; + + if (_p1d5CachedReadouts.TryGetValue(signal.Index, out cached) && cached.Cursor2 is { } c2) + controls.Cursor2.Text = FormatP1D5CachedCursorValue("C2", signal.Index, c2, representation); + else + missing |= P1D5MeasurementTargets.Cursor2; + } + return missing; + } + + private string FormatP1D5CachedCursorValue( + string cursor, + uint channelIndex, + P1D5CachedMeasurement cached, + int representation) + { + var measurement = cached.Measurement; + if (measurement is not { Valid: true }) + return ComtradeCursorReadoutPolicy.FormatValue(cursor, P1D5IsRmsTrace, null, CultureInfo.CurrentCulture); + + var sourceValue = P1D5IsRmsTrace ? measurement.Rms : measurement.Instantaneous; + var sourceScale = P1D5DisplayScale(channelIndex, cached.Representation); + var targetScale = P1D5DisplayScale(channelIndex, representation); + var converted = ComtradeRepresentationScaleMath.TryConvert( + sourceValue, + sourceScale, + targetScale, + magnitude: P1D5IsRmsTrace, + out var value) + ? value + : (double?)null; + return ComtradeCursorReadoutPolicy.FormatValue(cursor, P1D5IsRmsTrace, converted, CultureInfo.CurrentCulture); + } + + private void EnsureP1D5MeasurementRenderingPump() + { + if (_p1d5MeasurementRenderingHooked) + return; + CompositionTarget.Rendering += P1D5MeasurementCompositionFrame; + _p1d5MeasurementRenderingHooked = true; + } + + private void StopP1D5MeasurementRenderingPump() + { + if (!_p1d5MeasurementRenderingHooked) + return; + CompositionTarget.Rendering -= P1D5MeasurementCompositionFrame; + _p1d5MeasurementRenderingHooked = false; + } + + private void P1D5MeasurementCompositionFrame(object? sender, EventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) + { + StopP1D5MeasurementRenderingPump(); + return; + } + if (_p1d5MeasurementWorkerRunning || !_p1d5MeasurementDirty) + return; + + var targets = _p1d5PendingMeasurementTargets; + _p1d5PendingMeasurementTargets = P1D5MeasurementTargets.None; + _p1d5MeasurementDirty = false; + if (targets == P1D5MeasurementTargets.None) + { + StopP1D5MeasurementRenderingPump(); + return; + } + + var revision = Interlocked.Read(ref _p1d5MeasurementRevision); + var c1 = DisturbanceView.Cursor1Milliseconds; + var c2 = DisturbanceView.Cursor2Milliseconds; + ulong? c1Frame = c1 is { } first && TryResolveDisturbanceFrameAtMilliseconds(first, out var firstFrame) ? firstFrame : null; + ulong? c2Frame = c2 is { } second && TryResolveDisturbanceFrameAtMilliseconds(second, out var secondFrame) ? secondFrame : null; + if ((targets.HasFlag(P1D5MeasurementTargets.Cursor1) && c1Frame is null) && + (targets.HasFlag(P1D5MeasurementTargets.Cursor2) && c2Frame is null)) + { + StopP1D5MeasurementRenderingPump(); + return; + } + + var analogSignals = _p1d5VisibleAnalogTrackOrder; + if (analogSignals.Length == 0) + { + StopP1D5MeasurementRenderingPump(); + return; + } + + var token = EnsureP1D5MeasurementToken(); + _p1d5MeasurementWorkerRunning = true; + _p1d5InFlightMeasurementTargets = targets; + StopP1D5MeasurementRenderingPump(); + _ = ExecuteP1D5CursorMeasurementsAsync( + new P1D5MeasurementRequest(revision, analogSignals, c1Frame, c2Frame, _p1d5ValueRepresentation, targets), + token); + } + + private async Task ExecuteP1D5CursorMeasurementsAsync(P1D5MeasurementRequest request, CancellationToken token) + { + try + { + if (!P1D5MeasurementRequestIsCurrent(request.Revision, token)) + return; + + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + P1D5MeasurementResult result; + try + { + if (!P1D5MeasurementRequestIsCurrent(request.Revision, token)) + return; + + result = await Task.Run(() => ReadP1D5CursorMeasurements(request, token), token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + + if (!P1D5MeasurementRequestIsCurrent(request.Revision, token)) + return; + + await Dispatcher.InvokeAsync(() => + { + if (!P1D5MeasurementRequestIsCurrent(request.Revision, token) || + request.Revision < _p1d5LastPresentedMeasurementRevision) + return; + + PresentP1D5CursorMeasurements(result); + _p1d5LastPresentedMeasurementRevision = request.Revision; + }, DispatcherPriority.Background); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + ComtradeDiagnosticQueue.TryEnqueue( + "P1D5.CursorMeasurement", + "CURSOR_MEASUREMENT_FAILURE", + $"revision={request.Revision}; representation={request.Representation}; targets={request.Targets}", + ex); + } + finally + { + if (!Dispatcher.HasShutdownStarted && !Dispatcher.HasShutdownFinished) + { + try + { + await Dispatcher.InvokeAsync(CompleteP1D5MeasurementWorker, DispatcherPriority.Background); + } + catch (TaskCanceledException) + { + } + } + } + } + + private bool P1D5MeasurementRequestIsCurrent(long revision, CancellationToken token) + => ComtradeCursorReadoutPolicy.IsCurrent( + revision, + Interlocked.Read(ref _p1d5MeasurementRevision), + token.IsCancellationRequested); + + private void CompleteP1D5MeasurementWorker() + { + _p1d5MeasurementWorkerRunning = false; + _p1d5InFlightMeasurementTargets = P1D5MeasurementTargets.None; + if (_p1d5MeasurementDirty && _analysisMode == AnalysisMode.Waveform) + EnsureP1D5MeasurementRenderingPump(); + else + StopP1D5MeasurementRenderingPump(); + } + + private P1D5MeasurementResult ReadP1D5CursorMeasurements(P1D5MeasurementRequest request, CancellationToken token) + { + var rows = new P1D5MeasurementRow[request.Signals.Length]; + for (var index = 0; index < request.Signals.Length; index++) + { + token.ThrowIfCancellationRequested(); + var signal = request.Signals[index]; + ComtradeCursorMeasurement? c1 = null; + ComtradeCursorMeasurement? c2 = null; + if (request.Targets.HasFlag(P1D5MeasurementTargets.Cursor1) && request.Cursor1Frame is { } first) + _record.TryReadCursorMeasurement(signal.Index, first, request.Representation, out c1); + if (request.Targets.HasFlag(P1D5MeasurementTargets.Cursor2) && request.Cursor2Frame is { } second) + _record.TryReadCursorMeasurement(signal.Index, second, request.Representation, out c2); + rows[index] = new P1D5MeasurementRow(signal.Index, c1, c2); + } + return new P1D5MeasurementResult(rows, request.Targets, request.Representation); + } + + private void PresentP1D5CursorMeasurements(P1D5MeasurementResult result) + { + for (var index = 0; index < result.Rows.Length; index++) + { + var row = result.Rows[index]; + if (!_p1d5CursorReadoutControls.TryGetValue(row.ChannelIndex, out var controls)) + continue; + + if (!_p1d5CachedReadouts.TryGetValue(row.ChannelIndex, out var cached)) + { + cached = new P1D5CachedReadout(); + _p1d5CachedReadouts[row.ChannelIndex] = cached; + } + + if (result.Targets.HasFlag(P1D5MeasurementTargets.Cursor1)) + { + cached.Cursor1 = new P1D5CachedMeasurement(row.Cursor1, result.Representation); + controls.Cursor1.Text = FormatP1D5CursorValue("C1", row.Cursor1); + } + if (result.Targets.HasFlag(P1D5MeasurementTargets.Cursor2)) + { + cached.Cursor2 = new P1D5CachedMeasurement(row.Cursor2, result.Representation); + controls.Cursor2.Text = FormatP1D5CursorValue("C2", row.Cursor2); + } + } + } + + private string FormatP1D5CursorValue(string cursor, ComtradeCursorMeasurement? measurement) + { + double? value = measurement is { Valid: true } + ? P1D5IsRmsTrace ? measurement.Rms : measurement.Instantaneous + : null; + return ComtradeCursorReadoutPolicy.FormatValue(cursor, P1D5IsRmsTrace, value, CultureInfo.CurrentCulture); + } + + private CancellationToken EnsureP1D5MeasurementToken() + { + if (_p1d5MeasurementCts is null || _p1d5MeasurementCts.IsCancellationRequested) + { + _p1d5MeasurementCts?.Dispose(); + _p1d5MeasurementCts = new CancellationTokenSource(); + } + return _p1d5MeasurementCts.Token; + } + + private sealed record P1D5CursorReadoutControls(TextBlock Cursor1, TextBlock Cursor2); + private sealed class P1D5CachedReadout + { + internal P1D5CachedMeasurement? Cursor1 { get; set; } + internal P1D5CachedMeasurement? Cursor2 { get; set; } + } + + private readonly record struct P1D5CachedMeasurement( + ComtradeCursorMeasurement? Measurement, + int Representation); + + private readonly record struct P1D5MeasurementRequest( + long Revision, + ComtradeSignalItem[] Signals, + ulong? Cursor1Frame, + ulong? Cursor2Frame, + int Representation, + P1D5MeasurementTargets Targets); + private readonly record struct P1D5MeasurementRow( + uint ChannelIndex, + ComtradeCursorMeasurement? Cursor1, + ComtradeCursorMeasurement? Cursor2); + private sealed record P1D5MeasurementResult( + P1D5MeasurementRow[] Rows, + P1D5MeasurementTargets Targets, + int Representation); +} diff --git a/ComtradeWorkspaceWindow.P1D5Display.cs b/ComtradeWorkspaceWindow.P1D5Display.cs new file mode 100644 index 000000000..f3a6adc71 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5Display.cs @@ -0,0 +1,246 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const int P1D5RepresentationSecondary = 1; + private const int P1D5RepresentationPrimary = 2; + + private enum P1D5WaveformTraceMode + { + Instantaneous, + Rms + } + + private readonly Dictionary _p1d5SemanticsCache = new(); + private P1D5WaveformTraceMode _p1d5WaveformTraceMode = P1D5WaveformTraceMode.Instantaneous; + private int _p1d5ValueRepresentation = P1D5RepresentationSecondary; + private bool _p1d5PresentationRefreshRunning; + private bool? _p1d5HasConvertibleAnalog; + + private bool P1D5IsRmsTrace => _p1d5WaveformTraceMode == P1D5WaveformTraceMode.Rms; + private string P1D5RepresentationLabel => _p1d5ValueRepresentation == P1D5RepresentationPrimary ? "Primary" : "Secondary"; + + private void P1D5Workspace_Loaded(object sender, RoutedEventArgs e) + { + var work = SystemParameters.WorkArea; + if (WindowState == WindowState.Normal) + { + Width = Math.Min(Math.Max(MinWidth, 1360.0), Math.Max(MinWidth, work.Width - 20.0)); + Height = Math.Min(Math.Max(MinHeight, 960.0), Math.Max(MinHeight, work.Height - 20.0)); + } + + InitializeP1D5LocusUi(); + RefreshP1D5PresentationButtons(); + DisturbanceView.SetAnalogRepresentationLabel(P1D5RepresentationLabel); + QueueP1D5CursorMeasurements(); + } + + private async void InstantTrace_Click(object sender, RoutedEventArgs e) + => await SetP1D5WaveformTraceModeAsync(P1D5WaveformTraceMode.Instantaneous).ConfigureAwait(true); + + private async void RmsTrace_Click(object sender, RoutedEventArgs e) + => await SetP1D5WaveformTraceModeAsync(P1D5WaveformTraceMode.Rms).ConfigureAwait(true); + + private async void SecondaryValue_Click(object sender, RoutedEventArgs e) + => await SetP1D5ValueRepresentationAsync(P1D5RepresentationSecondary).ConfigureAwait(true); + + private async void PrimaryValue_Click(object sender, RoutedEventArgs e) + => await SetP1D5ValueRepresentationAsync(P1D5RepresentationPrimary).ConfigureAwait(true); + + private async Task SetP1D5WaveformTraceModeAsync(P1D5WaveformTraceMode mode) + { + if (_p1d5WaveformTraceMode == mode || _p1d5PresentationRefreshRunning) + return; + + _p1d5WaveformTraceMode = mode; + RefreshP1D5PresentationButtons(); + await RefreshP1D5PresentationAsync(reloadWaveform: true).ConfigureAwait(true); + } + + private async Task SetP1D5ValueRepresentationAsync(int representation) + { + representation = representation == P1D5RepresentationPrimary + ? P1D5RepresentationPrimary + : P1D5RepresentationSecondary; + if (_p1d5ValueRepresentation == representation || _p1d5PresentationRefreshRunning) + return; + + _p1d5ValueRepresentation = representation; + RefreshP1D5PresentationButtons(); + await RefreshP1D5RepresentationAsync().ConfigureAwait(true); + } + + /// + /// PRI/SEC is a positive per-channel engineering scale. Because every Time Signals lane is + /// independently auto-ranged, multiplying all samples in one lane by that scale cannot change + /// its normalized waveform geometry. Re-reading/rebuilding every source frame was therefore + /// pure latency. Existing C1/C2 measurements are projected immediately through the cached + /// transformer ratio, while native work is requested only for a value that is genuinely absent. + /// + private async Task RefreshP1D5RepresentationAsync() + { + if (_p1d5PresentationRefreshRunning) + return; + _p1d5PresentationRefreshRunning = true; + try + { + InvalidateP1D5AnalysisPresentationCaches(); + InvalidateP1D5CursorMeasurementGeneration(); + + var missingMeasurements = _analysisMode == AnalysisMode.Waveform + ? PresentP1D5CachedRepresentation(_p1d5ValueRepresentation) + : P1D5MeasurementTargets.None; + + DisturbanceView.SetAnalogRepresentationLabel(P1D5RepresentationLabel); + + if (_p1d5LocusActive) + { + await RefreshP1D5LocusStaticAsync(forceReopen: false).ConfigureAwait(true); + QueueP1D5LocusCursorRefresh(); + } + else if (_analysisMode == AnalysisMode.Waveform) + { + if (missingMeasurements != P1D5MeasurementTargets.None) + QueueP1D5CursorMeasurements(missingMeasurements, isFinal: true); + } + else + { + QueueP1D4LiveAnalysisScrub(isFinal: true); + } + + if (_analysisMode == AnalysisMode.Waveform && _disturbanceLoadedViewport.FrameCount > 0) + { + var traceMode = P1D5IsRmsTrace ? "RMS" : "instantaneous"; + StatusTextBlock.Text = $"Time Signals • {_disturbanceVisibleSignals.Count} tracks • {traceMode} • " + + $"{P1D5RepresentationLabel} • {_disturbanceLoadedViewport.FrameCount:N0} source frames • retained waveform"; + } + } + finally + { + _p1d5PresentationRefreshRunning = false; + } + } + + private async Task RefreshP1D5PresentationAsync(bool reloadWaveform) + { + if (_p1d5PresentationRefreshRunning) + return; + _p1d5PresentationRefreshRunning = true; + try + { + InvalidateP1D5AnalysisPresentationCaches(); + + if (reloadWaveform && _disturbanceInitialized) + { + await ReloadDisturbanceAsync( + CurrentDisturbanceViewport(), + initialLoad: false, + preserveLocalView: true).ConfigureAwait(true); + DisturbanceView.SetAnalogRepresentationLabel(P1D5RepresentationLabel); + } + + if (_p1d5LocusActive) + { + await RefreshP1D5LocusStaticAsync(forceReopen: false).ConfigureAwait(true); + QueueP1D5LocusCursorRefresh(); + } + else + { + QueueP1D5CursorMeasurements(); + if (_analysisMode != AnalysisMode.Waveform) + QueueP1D4LiveAnalysisScrub(isFinal: true); + } + } + finally + { + _p1d5PresentationRefreshRunning = false; + } + } + + private void InvalidateP1D5AnalysisPresentationCaches() + { + _p1d4PhasorFrameCache.Clear(); + _phasorFrameCache.Clear(); + _lastRenderedPhasorFrame = ulong.MaxValue; + _p1d4LastRenderedHarmonicOverviewFrame = ulong.MaxValue; + _p1d4LastRenderedHarmonicOverviewSignature = string.Empty; + } + + private void RefreshP1D5PresentationButtons() + { + ApplyP1D5ToggleButton(InstantTraceButton, !P1D5IsRmsTrace); + ApplyP1D5ToggleButton(RmsTraceButton, P1D5IsRmsTrace); + ApplyP1D5ToggleButton(SecondaryValueButton, _p1d5ValueRepresentation == P1D5RepresentationSecondary); + ApplyP1D5ToggleButton(PrimaryValueButton, _p1d5ValueRepresentation == P1D5RepresentationPrimary); + + var hasConvertibleAnalog = P1D5HasConvertibleAnalog(); + PrimaryValueButton.IsEnabled = hasConvertibleAnalog; + PrimaryValueButton.ToolTip = hasConvertibleAnalog + ? "Display analog values in primary engineering quantities using COMTRADE transformer ratios." + : "No valid primary/secondary transformer ratio is declared by this COMTRADE record."; + } + + private bool P1D5HasConvertibleAnalog() + { + if (_p1d5HasConvertibleAnalog is { } cached) + return cached; + + // Deliberately walk all channels once. Besides answering the button-enabled question this + // pre-warms the authoritative native semantics cache, so the first PRI/SEC toggle performs + // no channel-semantics bridge calls on the UI thread. + var result = false; + for (var index = 0; index < _record.AnalogChannels.Count; index++) + { + var semantics = P1D5AnalogSemantics(checked((uint)index)); + if (semantics is { HasValidTransformerRatio: true }) + result = true; + } + + _p1d5HasConvertibleAnalog = result; + return result; + } + + private static void ApplyP1D5ToggleButton(Button button, bool selected) + { + button.Foreground = new SolidColorBrush(selected ? Color.FromRgb(35, 86, 153) : Color.FromRgb(93, 111, 133)); + button.Background = new SolidColorBrush(selected ? Color.FromRgb(234, 243, 255) : Colors.White); + button.BorderBrush = new SolidColorBrush(selected ? Color.FromRgb(140, 177, 221) : Color.FromRgb(203, 216, 231)); + button.BorderThickness = new Thickness(1); + } + + private ComtradeAnalogSemantics? P1D5AnalogSemantics(uint channelIndex) + { + if (_p1d5SemanticsCache.TryGetValue(channelIndex, out var cached)) + return cached; + + ComtradeAnalogSemantics? semantics = null; + if (_record.Supports(ArdIrecNativeBridge.CapChannelSemantics)) + _record.TryReadAnalogSemantics(channelIndex, out semantics); + _p1d5SemanticsCache[channelIndex] = semantics; + return semantics; + } + + private double P1D5DisplayScale(uint channelIndex) + => P1D5DisplayScale(channelIndex, _p1d5ValueRepresentation); + + private double P1D5DisplayScale(uint channelIndex, int representation) + { + var semantics = P1D5AnalogSemantics(channelIndex); + if (semantics is not { HasValidTransformerRatio: true }) + return 1.0; + + var scale = representation == P1D5RepresentationPrimary + ? semantics.ScaleToPrimary + : semantics.ScaleToSecondary; + return ComtradeRepresentationScaleMath.IsUsableScale(scale) ? scale : 1.0; + } + + private double[] P1D5ScaleInstantaneous(IReadOnlyList values, uint channelIndex) + => ComtradeRmsSeriesBuilder.ApplyScale(values, P1D5DisplayScale(channelIndex)); +} diff --git a/ComtradeWorkspaceWindow.P1D5Locus.cs b/ComtradeWorkspaceWindow.P1D5Locus.cs new file mode 100644 index 000000000..5defe7e15 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5Locus.cs @@ -0,0 +1,408 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private const uint P1D5LocusMaximumPointsPerLoop = 900; + + private Button? _p1d5LocusButton; + private ComtradeLocusView? _p1d5LocusView; + private ArdIrecLocusNativeSession? _p1d5LocusSession; + private CancellationTokenSource? _p1d5LocusLoadCts; + private bool _p1d5LocusUiInitialized; + private bool _p1d5LocusActive; + private long _p1d5LocusLoadGeneration; + private long _p1d5LocusCursorRevision; + private bool _p1d5LocusCursorDirty; + private bool _p1d5LocusCursorWorkerRunning; + private bool _p1d5LocusCursorRenderingHooked; + + private void InitializeP1D5LocusUi() + { + if (_p1d5LocusUiInitialized) return; + _p1d5LocusUiInitialized = true; + + if (HarmonicsModeButton.Parent is Panel modePanel) + { + _p1d5LocusButton = new Button + { + Content = "Locus", + Height = 27, + MinWidth = 72, + Margin = new Thickness(5, 0, 0, 0), + Padding = new Thickness(12, 0, 12, 0), + FontSize = 10.5, + FontWeight = FontWeights.SemiBold, + Cursor = System.Windows.Input.Cursors.Hand, + ToolTip = "Protection R-X locus using the validated ArdIrec distance engine." + }; + var index = modePanel.Children.IndexOf(HarmonicsModeButton); + modePanel.Children.Insert(Math.Max(0, index + 1), _p1d5LocusButton); + _p1d5LocusButton.Click += P1D5Locus_Click; + ApplyModeButton(_p1d5LocusButton, false); + } + + if (WaveformWorkspaceHost.Parent is Grid workspaceGrid) + { + _p1d5LocusView = new ComtradeLocusView + { + Visibility = Visibility.Collapsed, + MinHeight = 360 + }; + workspaceGrid.Children.Add(_p1d5LocusView); + } + + WaveformModeButton.Click += P1D5StandardModeButton_Click; + PhasorModeButton.Click += P1D5StandardModeButton_Click; + HarmonicsModeButton.Click += P1D5StandardModeButton_Click; + InvestigationTimeline.CursorChanged += P1D5LocusTimeline_CursorChanged; + Closed += P1D5LocusWindow_Closed; + } + + private async void P1D5Locus_Click(object sender, RoutedEventArgs e) + { + StopP1D4LiveAnalysisScrub(); + SetAnalysisMode(AnalysisMode.Waveform); + _p1d5LocusActive = true; + + WaveformWorkspaceHost.Visibility = Visibility.Collapsed; + PhasorView.Visibility = Visibility.Collapsed; + HarmonicsView.Visibility = Visibility.Collapsed; + if (_p1d5LocusView is not null) _p1d5LocusView.Visibility = Visibility.Visible; + TimeNavigationPanel.Visibility = Visibility.Collapsed; + WaveformTraceModePanel.Visibility = Visibility.Collapsed; + + ApplyModeButton(WaveformModeButton, false); + ApplyModeButton(PhasorModeButton, false); + ApplyModeButton(HarmonicsModeButton, false); + if (_p1d5LocusButton is not null) ApplyModeButton(_p1d5LocusButton, true); + + InvestigationTimeline.SetMode(ComtradeInvestigationTimelineMode.DualCursor); + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + StatusTextBlock.Text = "Loading protection R-X locus…"; + await RefreshP1D5LocusStaticAsync(forceReopen: _p1d5LocusSession is null).ConfigureAwait(true); + } + + private void P1D5StandardModeButton_Click(object sender, RoutedEventArgs e) + { + if (!_p1d5LocusActive) return; + ExitP1D5LocusMode(); + } + + private void ExitP1D5LocusMode() + { + if (!_p1d5LocusActive) return; + _p1d5LocusActive = false; + unchecked { _p1d5LocusLoadGeneration++; } + _p1d5LocusLoadCts?.Cancel(); + _p1d5LocusLoadCts?.Dispose(); + _p1d5LocusLoadCts = null; + StopP1D5LocusCursorPump(); + _p1d5LocusCursorDirty = false; + + _p1d5LocusSession?.Dispose(); + _p1d5LocusSession = null; + if (_p1d5LocusView is not null) _p1d5LocusView.Visibility = Visibility.Collapsed; + if (_p1d5LocusButton is not null) ApplyModeButton(_p1d5LocusButton, false); + + WaveformWorkspaceHost.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + PhasorView.Visibility = _analysisMode == AnalysisMode.Phasor ? Visibility.Visible : Visibility.Collapsed; + HarmonicsView.Visibility = _analysisMode == AnalysisMode.Harmonics ? Visibility.Visible : Visibility.Collapsed; + TimeNavigationPanel.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + WaveformTraceModePanel.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + } + + private void P1D5LocusWindow_Closed(object? sender, EventArgs e) + { + _p1d5LocusActive = false; + _p1d5LocusLoadCts?.Cancel(); + _p1d5LocusLoadCts?.Dispose(); + _p1d5LocusLoadCts = null; + StopP1D5LocusCursorPump(); + _p1d5LocusSession?.Dispose(); + _p1d5LocusSession = null; + + if (_p1d5LocusButton is not null) _p1d5LocusButton.Click -= P1D5Locus_Click; + WaveformModeButton.Click -= P1D5StandardModeButton_Click; + PhasorModeButton.Click -= P1D5StandardModeButton_Click; + HarmonicsModeButton.Click -= P1D5StandardModeButton_Click; + InvestigationTimeline.CursorChanged -= P1D5LocusTimeline_CursorChanged; + } + + private async Task RefreshP1D5LocusStaticAsync(bool forceReopen = false) + { + if (!_p1d5LocusActive || _p1d5LocusView is null) return; + + unchecked { _p1d5LocusLoadGeneration++; } + if (_p1d5LocusLoadGeneration <= 0) _p1d5LocusLoadGeneration = 1; + var generation = _p1d5LocusLoadGeneration; + _p1d5LocusLoadCts?.Cancel(); + _p1d5LocusLoadCts?.Dispose(); + _p1d5LocusLoadCts = new CancellationTokenSource(); + var token = _p1d5LocusLoadCts.Token; + + if (forceReopen) + { + _p1d5LocusSession?.Dispose(); + _p1d5LocusSession = null; + } + + try + { + if (_p1d5LocusSession is null) + { + var open = await Task.Run(() => + { + var ok = ArdIrecLocusNativeSession.TryOpen(_record.CfgPath, out var session, out var error); + return (Ok: ok, Session: session, Error: error); + }, token).ConfigureAwait(true); + + if (token.IsCancellationRequested || generation != _p1d5LocusLoadGeneration || !_p1d5LocusActive) + { + open.Session?.Dispose(); + return; + } + + if (!open.Ok || open.Session is null) + { + _p1d5LocusView.ShowMessage("Protection locus", open.Error); + StatusTextBlock.Text = "Locus unavailable: the installed COMTRADE bridge does not include P1D.5 locus exports."; + return; + } + _p1d5LocusSession = open.Session; + } + + var sessionSnapshot = _p1d5LocusSession; + if (sessionSnapshot is null) return; + var representation = _p1d5ValueRepresentation; + var result = await Task.Run(() => BuildP1D5LocusSeries(sessionSnapshot, representation, token), token) + .ConfigureAwait(true); + + if (token.IsCancellationRequested || generation != _p1d5LocusLoadGeneration || !_p1d5LocusActive) + return; + + if (!result.Success) + { + _p1d5LocusView.ShowMessage("Protection locus", result.Error); + StatusTextBlock.Text = "Locus calculation unavailable • diagnostics captured."; + return; + } + + _p1d5LocusView.ShowTrajectories( + "Protection distance locus", + $"Full-record sampled trajectory • {P1D5RepresentationLabel} Ω • phase loops use validated differential V/I • earth loops shown with kL=0", + P1D5RepresentationLabel, + result.Earth, + result.Phase); + StatusTextBlock.Text = + $"Locus • {result.Earth.Count} earth + {result.Phase.Count} phase loop(s) • {P1D5RepresentationLabel} • native distance equations"; + QueueP1D5LocusCursorRefresh(); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + ComtradeDiagnosticQueue.TryEnqueue( + "P1D5.Locus", + "LOCUS_STATIC_FAILURE", + $"generation={generation}; representation={_p1d5ValueRepresentation}", + ex); + if (_p1d5LocusActive) + { + _p1d5LocusView.ShowMessage("Protection locus", "Locus calculation failed. Diagnostics captured."); + StatusTextBlock.Text = "Locus calculation failed • diagnostics captured."; + } + } + } + + private P1D5LocusLoadResult BuildP1D5LocusSeries( + ArdIrecLocusNativeSession session, + int representation, + CancellationToken token) + { + var earth = new List(3); + var phase = new List(3); + for (var loop = ArdIrecLocusNativeSession.LoopL1E; loop <= ArdIrecLocusNativeSession.LoopL3L1; loop++) + { + token.ThrowIfCancellationRequested(); + if (!session.TryReadLocus( + loop, + 0, + session.FrameCount, + P1D5LocusMaximumPointsPerLoop, + representation, + 0.0, + 0.0, + out var points, + out var error)) + { + ComtradeDiagnosticQueue.TryEnqueue( + "P1D5.Locus", + "LOCUS_LOOP_FAILURE", + $"loop={loop}; {error}", + null); + continue; + } + + var anyValid = false; + for (var index = 0; index < points.Length; index++) + { + if (points[index].Valid) + { + anyValid = true; + break; + } + } + if (!anyValid) continue; + + var item = new ComtradeLocusSeries( + ComtradeLocusView.LoopName(loop), + P1D5LocusColor(loop), + points); + if (loop <= ArdIrecLocusNativeSession.LoopL3E) earth.Add(item); + else phase.Add(item); + } + + if (earth.Count == 0 && phase.Count == 0) + return new P1D5LocusLoadResult(false, earth, phase, + "No valid protection loop can be formed from the record's mapped three-phase Voltage/Current channels."); + return new P1D5LocusLoadResult(true, earth, phase, string.Empty); + } + + private static Color P1D5LocusColor(int loop) => loop switch + { + ArdIrecLocusNativeSession.LoopL1E => Color.FromRgb(0, 146, 63), + ArdIrecLocusNativeSession.LoopL2E => Color.FromRgb(224, 0, 208), + ArdIrecLocusNativeSession.LoopL3E => Color.FromRgb(23, 105, 210), + ArdIrecLocusNativeSession.LoopL1L2 => Color.FromRgb(103, 137, 238), + ArdIrecLocusNativeSession.LoopL2L3 => Color.FromRgb(0, 161, 132), + ArdIrecLocusNativeSession.LoopL3L1 => Color.FromRgb(181, 104, 196), + _ => Color.FromRgb(111, 119, 128) + }; + + private void P1D5LocusTimeline_CursorChanged(object? sender, ComtradeInvestigationTimelineCursorChangedEventArgs e) + { + if (!_p1d5LocusActive || e.Cursor is not (ComtradeInvestigationTimelineCursor.Cursor1 or ComtradeInvestigationTimelineCursor.Cursor2)) + return; + QueueP1D5LocusCursorRefresh(); + } + + private void QueueP1D5LocusCursorRefresh() + { + if (!_p1d5LocusActive || _p1d5LocusSession is null) return; + unchecked { _p1d5LocusCursorRevision++; } + if (_p1d5LocusCursorRevision <= 0) _p1d5LocusCursorRevision = 1; + _p1d5LocusCursorDirty = true; + if (!_p1d5LocusCursorWorkerRunning) EnsureP1D5LocusCursorPump(); + } + + private void EnsureP1D5LocusCursorPump() + { + if (_p1d5LocusCursorRenderingHooked) return; + CompositionTarget.Rendering += P1D5LocusCompositionFrame; + _p1d5LocusCursorRenderingHooked = true; + } + + private void StopP1D5LocusCursorPump() + { + if (!_p1d5LocusCursorRenderingHooked) return; + CompositionTarget.Rendering -= P1D5LocusCompositionFrame; + _p1d5LocusCursorRenderingHooked = false; + } + + private void P1D5LocusCompositionFrame(object? sender, EventArgs e) + { + if (!_p1d5LocusActive || _p1d5LocusSession is null) + { + StopP1D5LocusCursorPump(); + return; + } + if (_p1d5LocusCursorWorkerRunning || !_p1d5LocusCursorDirty) return; + + _p1d5LocusCursorDirty = false; + var revision = _p1d5LocusCursorRevision; + ulong? c1Frame = DisturbanceView.Cursor1Milliseconds is { } c1 && + TryResolveDisturbanceFrameAtMilliseconds(c1, out var first) ? first : null; + ulong? c2Frame = DisturbanceView.Cursor2Milliseconds is { } c2 && + TryResolveDisturbanceFrameAtMilliseconds(c2, out var second) ? second : null; + if (c1Frame is null && c2Frame is null) + { + StopP1D5LocusCursorPump(); + return; + } + + var session = _p1d5LocusSession; + var representation = _p1d5ValueRepresentation; + _p1d5LocusCursorWorkerRunning = true; + StopP1D5LocusCursorPump(); + _ = ExecuteP1D5LocusCursorAsync(session, representation, c1Frame, c2Frame, revision); + } + + private async Task ExecuteP1D5LocusCursorAsync( + ArdIrecLocusNativeSession session, + int representation, + ulong? c1Frame, + ulong? c2Frame, + long revision) + { + try + { + var result = await Task.Run(() => + { + ComtradeDistancePoint[] c1 = Array.Empty(); + ComtradeDistancePoint[] c2 = Array.Empty(); + string error = string.Empty; + if (c1Frame is { } first && !session.TryReadLoops(first, representation, 0.0, 0.0, out c1, out error)) + return new P1D5LocusCursorResult(false, c1, c2, error); + if (c2Frame is { } second && !session.TryReadLoops(second, representation, 0.0, 0.0, out c2, out error)) + return new P1D5LocusCursorResult(false, c1, c2, error); + return new P1D5LocusCursorResult(true, c1, c2, string.Empty); + }).ConfigureAwait(true); + + if (!_p1d5LocusActive || revision != _p1d5LocusCursorRevision || !ReferenceEquals(session, _p1d5LocusSession)) + return; + if (!result.Success) + { + ComtradeDiagnosticQueue.TryEnqueue("P1D5.Locus", "LOCUS_CURSOR_FAILURE", result.Error, null); + return; + } + _p1d5LocusView?.SetCursorPoints(result.Cursor1, result.Cursor2); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + ComtradeDiagnosticQueue.TryEnqueue( + "P1D5.Locus", "LOCUS_CURSOR_FAILURE", $"revision={revision}", ex); + } + finally + { + _p1d5LocusCursorWorkerRunning = false; + if (_p1d5LocusCursorDirty && _p1d5LocusActive) EnsureP1D5LocusCursorPump(); + } + } + + private sealed record P1D5LocusLoadResult( + bool Success, + IReadOnlyList Earth, + IReadOnlyList Phase, + string Error); + + private sealed record P1D5LocusCursorResult( + bool Success, + IReadOnlyList Cursor1, + IReadOnlyList Cursor2, + string Error); +} diff --git a/ComtradeWorkspaceWindow.P1D5Selection.cs b/ComtradeWorkspaceWindow.P1D5Selection.cs new file mode 100644 index 000000000..23490310f --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5Selection.cs @@ -0,0 +1,258 @@ +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using ArIED61850Tester.Controls; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + private ComtradeSelectionNavigationSnapshot _p1d5SuspendedSelectionNavigation; + private double _p1d5SuspendedTrackScrollOffset; + private bool _p1d5AutoReloadRunning; + private int _p1d5SelectionGeneration; + + /// + /// P1D.5 Auto is a single bounded selection transaction. It never rebuilds the workstation when + /// the practical default selection is already active, and after Clear it restores the exact + /// investigation viewport/cursors instead of falling back to the old full-record presentation. + /// + private async void P1D5AutoSignals_Click(object sender, RoutedEventArgs e) + { + if (_p1d5AutoReloadRunning) + return; + + var previousSelection = _disturbanceVisibleSignals.ToArray(); + BuildDefaultVisibleSignals(); + SyncSignalVisibilityCheckboxes(); + + var alreadyDefault = previousSelection.Length == _disturbanceVisibleSignals.Count; + if (alreadyDefault) + { + for (var index = 0; index < previousSelection.Length; index++) + { + if (_disturbanceVisibleSignals.Contains(previousSelection[index])) + continue; + alreadyDefault = false; + break; + } + } + + // Repeated Auto must be O(n) UI state only. Do not touch the native bridge when the exact + // default set is already on screen and there is no suspended Clear state to restore. + if (alreadyDefault && !_p1d5SuspendedSelectionNavigation.IsValid && + DisturbanceView.FullEndMilliseconds > DisturbanceView.FullStartMilliseconds) + { + StatusTextBlock.Text = "Auto signal set is already active."; + return; + } + + _p1d5AutoReloadRunning = true; + try + { + await ReloadP1D5VisibleSelectionAsync( + restoreSuspendedNavigation: _p1d5SuspendedSelectionNavigation.IsValid, + applyTriggerFallback: true).ConfigureAwait(true); + } + finally + { + _p1d5AutoReloadRunning = false; + } + } + + private async void P1D5SignalVisibility_Checked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (_disturbanceVisibleSignals.Contains(signal)) + return; + if (_disturbanceVisibleSignals.Count >= MaxVisibleDisturbanceTracks) + { + _disturbanceCheckboxSync = true; + checkBox.IsChecked = false; + _disturbanceCheckboxSync = false; + StatusTextBlock.Text = $"Time Signals supports up to {MaxVisibleDisturbanceTracks} visible tracks at once. Hide another signal first."; + return; + } + + var restoringFromEmpty = _disturbanceVisibleSignals.Count == 0 && _p1d5SuspendedSelectionNavigation.IsValid; + _disturbanceVisibleSignals.Add(signal); + if (restoringFromEmpty) + { + await ReloadP1D5VisibleSelectionAsync( + restoreSuspendedNavigation: true, + applyTriggerFallback: true).ConfigureAwait(true); + return; + } + + await ReloadP1D5IncrementalSelectionAsync().ConfigureAwait(true); + } + + private async void P1D5SignalVisibility_Unchecked(object sender, RoutedEventArgs e) + { + if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) + return; + if (!_disturbanceVisibleSignals.Contains(signal)) + return; + + if (_disturbanceVisibleSignals.Count == 1) + { + CaptureP1D5SelectionNavigation(); + _disturbanceVisibleSignals.Remove(signal); + Interlocked.Increment(ref _p1d5SelectionGeneration); + CancelP1D5SelectionWork(); + PresentP1D5EmptySelection(); + return; + } + + _disturbanceVisibleSignals.Remove(signal); + await ReloadP1D5IncrementalSelectionAsync().ConfigureAwait(true); + } + + /// + /// Clear is presentation-only and must be immediate: no native reload, no frame rebuild, no + /// cursor measurement work. Keep one small navigation snapshot so Auto or a manually reselected + /// signal can restore the same investigation context. + /// + private void P1D5ClearSignals_Click(object sender, RoutedEventArgs e) + { + Interlocked.Increment(ref _p1d5SelectionGeneration); + CaptureP1D5SelectionNavigation(); + CancelP1D5SelectionWork(); + + _disturbanceVisibleSignals.Clear(); + SyncSignalVisibilityCheckboxes(); + PresentP1D5EmptySelection(); + } + + private async Task ReloadP1D5IncrementalSelectionAsync() + { + var generation = Interlocked.Increment(ref _p1d5SelectionGeneration); + InvalidateP1D5MeasurementWork(); + await ReloadDisturbanceAsync( + CurrentDisturbanceViewport(), + initialLoad: false, + preserveLocalView: true).ConfigureAwait(true); + + if (generation != Volatile.Read(ref _p1d5SelectionGeneration)) + return; + if (DisturbanceView.FullEndMilliseconds <= DisturbanceView.FullStartMilliseconds) + return; + + InvestigationTimeline.IsEnabled = true; + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + QueueP1D5CursorMeasurements(); + } + + private async Task ReloadP1D5VisibleSelectionAsync( + bool restoreSuspendedNavigation, + bool applyTriggerFallback) + { + var generation = Interlocked.Increment(ref _p1d5SelectionGeneration); + var suspended = restoreSuspendedNavigation ? _p1d5SuspendedSelectionNavigation : default; + var requestedViewport = suspended.IsValid + ? new ComtradeSourceViewport(suspended.SourceStartFrame, suspended.SourceFrameCount) + : CurrentDisturbanceViewport(); + + InvestigationTimeline.IsEnabled = false; + InvalidateP1D5MeasurementWork(); + await ReloadDisturbanceAsync( + requestedViewport, + initialLoad: false, + preserveLocalView: false).ConfigureAwait(true); + + if (generation != Volatile.Read(ref _p1d5SelectionGeneration)) + return; + + var hasLoadedTimeline = DisturbanceView.FullEndMilliseconds > DisturbanceView.FullStartMilliseconds; + if (hasLoadedTimeline && suspended.MatchesSource( + _disturbanceLoadedViewport.StartFrame, + _disturbanceLoadedViewport.FrameCount)) + { + DisturbanceView.SetViewWindow( + suspended.ViewStartMilliseconds, + suspended.ViewEndMilliseconds); + if (suspended.Cursor1Milliseconds is { } c1) + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, c1); + if (suspended.Cursor2Milliseconds is { } c2) + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor2, c2); + DisturbanceScrollViewer.ScrollToVerticalOffset(_p1d5SuspendedTrackScrollOffset); + } + else if (hasLoadedTimeline && applyTriggerFallback) + { + // If there was no restorable local view, retain the modern trigger-focused P1D.5 + // behavior rather than silently reverting to the historical full-record UX. + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + } + + if (!hasLoadedTimeline) + return; + + _disturbanceInitialFocusApplied = true; + _p1d5SuspendedSelectionNavigation = default; + _p1d5SuspendedTrackScrollOffset = 0; + InvestigationTimeline.IsEnabled = true; + SyncInvestigationTimeline(); + SyncInvestigationTimelineGeometry(); + QueueP1D5CursorMeasurements(); + } + + private void CaptureP1D5SelectionNavigation() + { + var snapshot = ComtradeSelectionNavigationSnapshot.Capture( + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + DisturbanceView.Cursor1Milliseconds, + DisturbanceView.Cursor2Milliseconds, + _disturbanceLoadedViewport.StartFrame, + _disturbanceLoadedViewport.FrameCount); + if (!snapshot.IsValid) + return; + + _p1d5SuspendedSelectionNavigation = snapshot; + _p1d5SuspendedTrackScrollOffset = DisturbanceScrollViewer.VerticalOffset; + } + + private void PresentP1D5EmptySelection() + { + DisturbanceView.ShowMessage("Select signals to display."); + DigitalEventGrid.ItemsSource = Array.Empty(); + DigitalEventExpander.Visibility = Visibility.Collapsed; + ResetViewButton.IsEnabled = false; + FullRecordButton.IsEnabled = false; + InvestigationTimeline.IsEnabled = false; + StatusTextBlock.Text = "No Time Signals tracks selected • use the checkboxes in Signals or choose Auto."; + NavigationTextBlock.Text = "Selection cleared • the next selection restores the previous investigation window."; + } + + private void CancelP1D5SelectionWork() + { + _disturbanceLoadCts?.Cancel(); + _disturbanceLoadCts?.Dispose(); + _disturbanceLoadCts = null; + + _disturbanceCursorSnapCts?.Cancel(); + _disturbanceCursorSnapCts?.Dispose(); + _disturbanceCursorSnapCts = null; + InvalidateP1D5MeasurementWork(); + } + + private void InvalidateP1D5MeasurementWork() + { + // Selection changes invalidate every cursor request that referenced the previous visible + // channels. Clear BOTH projections; leaving the analog projection alive was able to keep + // native C1/C2 work queued behind Auto's track reload and made the workstation feel frozen. + Interlocked.Increment(ref _p1d5MeasurementRevision); + _p1d5MeasurementDirty = false; + StopP1D5MeasurementRenderingPump(); + _p1d5MeasurementCts?.Cancel(); + _p1d5MeasurementCts?.Dispose(); + _p1d5MeasurementCts = null; + _p1d5VisibleTrackOrder = Array.Empty(); + _p1d5VisibleAnalogTrackOrder = Array.Empty(); + CursorReadoutCanvas.Children.Clear(); + _p1d5CursorReadoutControls.Clear(); + } +} diff --git a/ComtradeWorkspaceWindow.P1D6Performance.cs b/ComtradeWorkspaceWindow.P1D6Performance.cs new file mode 100644 index 000000000..cdbc8669c --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D6Performance.cs @@ -0,0 +1,9 @@ +namespace ArIED61850Tester; + +public partial class ComtradeWorkspaceWindow +{ + // Existing dispatcher callbacks use QueueP1D5CursorMeasurements as a parameterless Action. + // Keep that contract while the hot path can additionally target only C1 or C2. + private void QueueP1D5CursorMeasurements() + => QueueP1D5CursorMeasurements(P1D5MeasurementTargets.Both); +} diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 64591a169..e2cca1f0a 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -3,104 +3,135 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ArIED61850Tester.Controls" Title="ARSAS — COMTRADE Workspace" - Width="1220" Height="760" MinWidth="920" MinHeight="560" + Width="1360" Height="960" MinWidth="1040" MinHeight="700" WindowStartupLocation="CenterOwner" - Background="#F4F7FB" + Loaded="P1D5Workspace_Loaded" + Background="#F3F6FA" FontFamily="Segoe UI"> - + + + + + + - - - + - + - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + - + + + - - + + + + +