diff --git a/.github/workflows/comtrade-viewer-integration.yml b/.github/workflows/comtrade-viewer-integration.yml index f7991f3b0..988483ff3 100644 --- a/.github/workflows/comtrade-viewer-integration.yml +++ b/.github/workflows/comtrade-viewer-integration.yml @@ -4,30 +4,15 @@ on: pull_request: paths: - "FaultRecordWindow.ComtradeOpen.cs" + - "ComtradeWorkspaceWindow*.cs" - "ComtradeWorkspaceWindow.xaml" - - "ComtradeWorkspaceWindow.xaml.cs" - - "ComtradeWorkspaceWindow.Analysis.cs" - - "ComtradeWorkspaceWindow.Disturbance.cs" - - "Controls/ComtradeWaveformView.cs" - - "Controls/ComtradeDisturbanceView.cs" - - "Controls/ComtradePhasorView.cs" - - "Controls/ComtradeHarmonicsView.cs" - - "Services/ArdIrecNativeBridge.cs" - - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeAbsoluteViewportMath.cs" - - "Services/ComtradeRangeDecimator.cs" - - "Services/ComtradeDecimatedSeriesBuilder.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" + - "Controls/Comtrade*.cs" + - "Services/ArdIrec*.cs" + - "Services/Comtrade*.cs" + - "tests/ARSAS.Tests/ArdIrec*Tests.cs" + - "tests/ARSAS.Tests/Comtrade*Tests.cs" - "scripts/stage-ardirec-viewer.ps1" + - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" - "docs/COMTRADE_VIEWER_INTEGRATION.md" - ".github/workflows/comtrade-viewer-integration.yml" @@ -38,7 +23,7 @@ permissions: jobs: windows-viewer-integration: - name: Build pinned ArdIrec P1C engine and smoke compatibility launch + name: Qualify native ARSAS COMTRADE workstation runs-on: windows-latest timeout-minutes: 35 @@ -48,32 +33,41 @@ jobs: with: path: ARSAS - - name: Resolve immutable ArdIrec integration lock + - name: Resolve immutable engine locks 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.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') { - throw "ArdIrec native bridge contract is invalid." + $iecLock = Get-Content ".\ARSAS\engines\ARIEC61850.lock.json" -Raw | ConvertFrom-Json + if ($iecLock.repository -notmatch '^[^/]+/[^/]+$' -or + $iecLock.commit -notmatch '^[0-9a-f]{40}$') { + throw "ARIEC61850 lock metadata 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." + + $lock = Get-Content ".\ARSAS\engines\ARDIREC.lock.json" -Raw | ConvertFrom-Json + if ($lock.schema -ne 3 -or + $lock.repository -notmatch '^[^/]+/[^/]+$' -or + $lock.commit -notmatch '^[0-9a-f]{40}$' -or + $lock.bridge.abi -ne 1 -or + $lock.bridge.relativeLibrary -ne 'Tools/ArdIrec/ardirec_bridge.dll' -or + $lock.bridge.mode -ne 'native-only') { + throw "ArdIrec P1D.5 bridge-only lock metadata is invalid." } + "ARIEC61850_REPOSITORY=$($iecLock.repository)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "ARIEC61850_COMMIT=$($iecLock.commit)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "ARDIREC_REPOSITORY=$($lock.repository)" | 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 ARIEC61850 revision + shell: powershell + run: | + git clone --quiet --filter=blob:none --no-checkout "https://github.com/$env:ARIEC61850_REPOSITORY.git" ARIEC61850 + 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." + } + - name: Checkout immutable ArdIrec revision shell: powershell run: | @@ -85,33 +79,39 @@ jobs: throw "ArdIrec pin mismatch. Expected $env:ARDIREC_COMMIT, got $actual." } - - name: Install Qt 6.8.3 - uses: jurplel/install-qt-action@v4 + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 with: - version: '6.8.3' - arch: 'win64_msvc2022_64' - cache: true + dotnet-version: 8.0.x - - name: Build, test and stage ArdIrec P1C engine + - name: Build and test ARSAS + shell: powershell + run: | + dotnet restore .\ARSAS\ArIED61850Tester.sln + if ($LASTEXITCODE -ne 0) { throw "Solution restore failed." } + dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + if ($LASTEXITCODE -ne 0) { throw "Solution build failed." } + dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore + if ($LASTEXITCODE -ne 0) { throw "Application regression tests failed." } + + - name: Build test and stage native ArdIrec bridge shell: powershell run: | - $publish = Join-Path $env:RUNNER_TEMP "arsas-viewer-publish" + $publish = Join-Path $env:RUNNER_TEMP "arsas-comtrade-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 ` -ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec" ` -PublishedDirectory $publish ` - -BuildDirectory "$env:RUNNER_TEMP\ardirec-build" - - "VIEWER_PUBLISH=$publish" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + -BuildDirectory "$env:RUNNER_TEMP\ardirec-bridge-build" + "COMTRADE_PUBLISH=$publish" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Verify native bridge and compatibility runtime - shell: pwsh + - name: Verify bridge-only deployment contract + shell: powershell run: | - $viewer = Join-Path $env:VIEWER_PUBLISH "Tools\ArdIrec\ardirec.exe" + $bridge = Join-Path $env:COMTRADE_PUBLISH "Tools\ArdIrec\ardirec_bridge.dll" + if (-not (Test-Path $bridge -PathType Leaf)) { throw "Native bridge was not staged: $bridge" } foreach ($relative in @( - "Tools\ArdIrec\ardirec_bridge.dll", "Tools\ArdIrec\ardirec.exe", "Tools\ArdIrec\Qt6Core.dll", "Tools\ArdIrec\Qt6Gui.dll", @@ -119,35 +119,22 @@ jobs: "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 P1C runtime file: $path" - } - } - - $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))." + $path = Join-Path $env:COMTRADE_PUBLISH $relative + if (Test-Path $path) { throw "Removed Qt/desktop fallback returned to package: $path" } } + "ARSAS_ARDIREC_BRIDGE_PATH=$bridge" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - Stop-Process -Id $process.Id -Force - $process.WaitForExit() - Write-Host "P1C native bridge is staged; compatibility Qt launch also passed with a Unicode COMTRADE path containing spaces." + - name: Exercise managed bridge and distance locus against ArdIrec fixtures + shell: powershell + run: | + $basic = Join-Path $env:RUNNER_TEMP "COMTRADE üñîçødé 日本 with spaces" + New-Item -ItemType Directory -Path $basic -Force | Out-Null + Copy-Item ".\ArdIrec\tests\data\minimal_1999.cfg" (Join-Path $basic "minimal_1999.cfg") -Force + Copy-Item ".\ArdIrec\tests\data\minimal_1999.dat" (Join-Path $basic "minimal_1999.dat") -Force + $env:ARSAS_NATIVE_COMTRADE_TEST_CFG = Join-Path $basic "minimal_1999.cfg" + $env:ARSAS_NATIVE_LOCUS_TEST_CFG = Join-Path $env:GITHUB_WORKSPACE "ArdIrec\tests\data\distance_p1.cfg" + + dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj ` + -c Release --no-build --no-restore ` + --filter "FullyQualifiedName~ArdIrecNativeBridgeIntegrationTests|FullyQualifiedName~ArdIrecLocusNativeSessionIntegrationTests" + if ($LASTEXITCODE -ne 0) { throw "Managed ArdIrec bridge/locus integration test failed." } diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index b75f209b5..9b529905d 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -10,29 +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" - - "ComtradeWorkspaceWindow.Analysis.cs" - - "ComtradeWorkspaceWindow.Disturbance.cs" - - "Controls/ComtradeWaveformView.cs" - - "Controls/ComtradeDisturbanceView.cs" - - "Controls/ComtradePhasorView.cs" - - "Controls/ComtradeHarmonicsView.cs" - - "Services/ArdIrecNativeBridge.cs" - - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeAbsoluteViewportMath.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/ComtradeAbsoluteViewportMathTests.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" @@ -46,29 +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" - - "ComtradeWorkspaceWindow.Analysis.cs" - - "ComtradeWorkspaceWindow.Disturbance.cs" - - "Controls/ComtradeWaveformView.cs" - - "Controls/ComtradeDisturbanceView.cs" - - "Controls/ComtradePhasorView.cs" - - "Controls/ComtradeHarmonicsView.cs" - - "Services/ArdIrecNativeBridge.cs" - - "Services/ArdIrecViewerLauncher.cs" - - "Services/ComtradeNavigationMath.cs" - - "Services/ComtradeTimeMath.cs" - - "Services/ComtradeAbsoluteViewportMath.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/ComtradeAbsoluteViewportMathTests.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" @@ -81,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 @@ -99,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 @@ -138,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 @@ -186,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 ` @@ -194,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 @@ -226,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)." } @@ -250,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", @@ -258,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/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a82acd6a8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# ARSAS Engineering Guardrails + +These instructions apply to every code change in this repository unless a narrower directory-level `AGENTS.md` explicitly strengthens them. They are architecture rules, not optional style advice. + +## 1. Production-ready from the first implementation + +Do not land a deliberately naive, temporary, or "simple first" implementation on a production path. The first committed design must already account for realistic data volume, slow devices, partial data, cancellation, concurrent UI activity, malformed input, and long-running sessions. + +Prefer bounded algorithms, deterministic state transitions, immutable/cached presentation data, and explicit failure states. Do not accept known lag, crash, unbounded memory growth, or correctness debt with the intention of fixing it later. + +## 2. Defensive programming and fail-safe behavior + +Treat all external, file, native, network, device, user, and persisted data as untrusted until validated. + +- Validate ranges, lengths, indexes, enum values, timestamps, units, counts, and finite floating-point values before use. +- Never assume arrays from different sources have identical lengths; use validated common bounds. +- Clamp UI coordinates and time/frame requests to known valid domains. +- Bound collections and work queues. A corrupted input must not cause an unbounded allocation or loop. +- A failed optional feature must degrade locally and must not crash the workstation or corrupt shared state. +- Cleanup must be idempotent. Cancellation, close, disconnect, or repeated teardown must be safe. + +## 3. Exception-free critical data paths and Result/Try patterns + +Exceptions are not control flow for high-frequency or critical processing. + +For parsers, conversion, analysis requests, native result decoding, timeline calculations, measurement processing, persistence validation, and other critical data functions: + +- Prefer `Try...` APIs, result structs/records, status enums, or explicit success/failure objects. +- Return failure details as data instead of throwing for expected malformed input, unavailable data, unsupported capability, cancellation-aware rejection, or validation failure. +- Do not add `throw` to hot paths to represent an expected runtime state. +- Do not use broad `catch (Exception)` as the normal boundary for routine processing. If an exception boundary is unavoidable at an OS/native/framework edge, contain it at that boundary, translate it immediately to a structured result, enqueue diagnostics asynchronously, and keep exception objects out of render/update loops. +- Cancellation is a state, not an error. Prefer early cancellation checks and cancelled result states where this can avoid exception churn. Framework APIs that necessarily throw `OperationCanceledException` may be contained at the task boundary only. + +A practical result shape should carry at least success/state, stable error code, concise operator-safe message, and optional diagnostic context without requiring exception propagation. + +## 4. Asynchronous internal diagnostics + +Failures that matter to engineering diagnosis must not burden interactive UI paths. + +- Route diagnostics through a bounded, asynchronous internal diagnostic queue or existing equivalent diagnostic service. +- UI/status text receives only concise operator-facing state; detailed context goes to diagnostics. +- Never synchronously write large logs, serialize reports, or perform filesystem/network diagnostics from cursor, render, packet, measurement, or polling callbacks. +- Apply backpressure/coalescing/deduplication to repeated diagnostic events. +- Diagnostics must never become a new crash source; queue failure is non-fatal. + +## 5. Hot-path performance rules + +The following are hot paths when active: rendering, cursor/scrub movement, mouse move, waveform navigation, live value updates, protocol receive callbacks, measurement loops, polling, packet processing, and device state projection. + +On hot paths: + +- No blocking I/O. +- No synchronous waits on async work (`.Wait()`, `.Result`, busy waiting). +- Avoid LINQ and iterator pipelines when they allocate or repeat enumeration per frame/update. +- Avoid rebuilding dictionaries, lists, formatted text, geometries, brushes, pens, or large strings every frame when data is unchanged. +- Cache immutable/static drawing layers separately from rapidly changing overlays. +- Freeze WPF `Freezable` objects when safe and reuse them. +- Coalesce high-frequency updates to the presentation cadence and enforce latest-wins semantics. +- Allow at most the explicitly designed number of in-flight workers. Never create one background task per mouse move, packet, or value change. +- Use O(1) or O(log n) lookup on repeated interactive searches where practical; pre-index sorted snap/event data rather than linearly scanning on every cursor move. +- Long-record visualization must be bounded/decimated while preserving exact source identity for drill-down. + +## 6. UI responsiveness and thread ownership + +The WPF dispatcher owns UI objects only. Native/file/network/CPU-heavy work belongs off the UI thread. + +- Keep dispatcher work small and presentation-only. +- Do not perform native scans, file reads, DFT/harmonic calculations, large parsing, or report generation on the UI thread. +- Background results must be generation/revision checked before presentation so stale results cannot overwrite newer user intent. +- Mode switches, close/dispose, and new requests must invalidate or cancel obsolete work. +- Pointer feedback must remain synchronous and lightweight even if deeper analysis is still computing. + +## 7. Deterministic state and stable ordering + +Never let `HashSet`, dictionary enumeration, task completion order, or checkbox activation order accidentally define operator-visible ordering. + +- Define canonical ordering for operator-visible collections. +- Preserve relative order inside semantic categories unless a documented sort key applies. +- One concept has one authority: cursor identity, timebase, selection, connection ownership, measurement state, and device state must not have competing sources of truth. +- Derived UI must project from authoritative state rather than maintaining an independent shadow state. + +For COMTRADE Time Signals specifically: selected analog tracks are always rendered before selected digital/protection tracks; selection order must not alter that category ordering. + +## 8. Memory, allocation, and resource bounds + +Every long-lived cache, queue, history, evidence buffer, waveform data set, and diagnostic buffer must have a documented bound or lifecycle. + +- Prefer fixed-size/ring/LRU-style bounded caches where retention is useful. +- Release obsolete cancellation tokens, event subscriptions, native handles, timers, streams, and large buffers promptly. +- Avoid copying large arrays unless the copy provides a measured or correctness benefit. +- Reuse immutable data between views when authority and lifetime are clear. +- Never retain UI objects in global/static caches. + +## 9. Native and protocol boundaries + +Native ArdIrec/ARIEC61850 and protocol integrations are authoritative engineering boundaries. + +- Validate native capability before use. +- Check native return/status values before reading output buffers. +- Do not duplicate authoritative native calculations in managed UI code merely for convenience. +- Keep native calls outside render callbacks. +- Serialize access only where the native contract requires it; do not use a broad lock/gate that unnecessarily blocks unrelated UI work. +- Preserve raw/source frame identity through decimation and projection so engineering evidence remains traceable. + +## 10. Regression-proof changes + +Every bug fix or architecture rule that can regress must gain an automated contract where practical. + +At minimum, tests should cover: + +- the field-reported failure mode; +- boundary and malformed input behavior; +- cancellation/stale-result behavior where asynchronous work is involved; +- deterministic ordering/identity rules; +- large/bounded data behavior for algorithms designed to protect responsiveness. + +A green build alone is not enough for a performance claim. Add deterministic allocation/algorithmic contracts and, where stable in CI, benchmark or latency budgets. Do not add flaky wall-clock tests that depend on shared-runner speed. + +## 11. Performance acceptance + +For interactive workstation features, reason about and document four budgets: + +1. work per pointer/render/update event; +2. maximum in-flight asynchronous work; +3. maximum retained memory/cache size; +4. stale-work invalidation/cancellation behavior. + +Prefer algorithmic tests for these budgets. Use field/benchmark evidence for actual latency numbers; never claim a specific p95/p99 latency without measurement. + +## 12. Change discipline + +Before changing architecture: + +- inspect existing authority, lifecycle, tests, and hot-path design; +- preserve working field behavior unless the change intentionally corrects it; +- modify the smallest authoritative layer rather than adding another parallel mechanism; +- avoid copy-pasted alternative implementations; +- keep commits focused and diagnosable; +- do not merge a field-test PR until exact-head CI is green and the requested field acceptance has been received. + +## 13. COMTRADE workstation invariants + +For the COMTRADE workspace, preserve all of the following unless a newer accepted specification explicitly replaces them: + +- a shared corrected timebase and trigger origin; +- one authoritative snap index for visible digital edges; +- C1/C2 waveform and upper ruler represent the same cursor identities; +- one P cursor for Phasor and one H cursor for Harmonics; +- interactive cursor feedback must not rebuild waveform data geometry; +- Phasor/Harmonics analysis is coalesced and stale results cannot flash back after a newer/final request; +- large records use bounded overview data plus exact source-frame drill-down; +- accented/legacy CFG labels degrade safely instead of corrupting the workstation; +- Harmonics uses native ArdIrec analysis and presents checked analog channels consistently; +- selected analog tracks render above all selected digital tracks. + +When a requested change conflicts with one of these invariants, redesign the authority intentionally; do not patch around it with another shadow state. diff --git a/ComtradeWorkspaceWindow.Analysis.cs b/ComtradeWorkspaceWindow.Analysis.cs index b4140160f..abf52d981 100644 --- a/ComtradeWorkspaceWindow.Analysis.cs +++ b/ComtradeWorkspaceWindow.Analysis.cs @@ -16,10 +16,23 @@ private enum AnalysisMode } private AnalysisMode _analysisMode = AnalysisMode.Waveform; - private ComtradeDisturbanceCursor _phasorReferenceCursor = ComtradeDisturbanceCursor.Cursor1; - private CancellationTokenSource? _analysisLoadCts; + 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); @@ -34,6 +47,7 @@ protected override void OnContentRendered(EventArgs e) private void AnalysisWindow_Closed(object? sender, EventArgs e) { + StopAnalysisRenderingPump(); _analysisLoadCts?.Cancel(); _analysisLoadCts?.Dispose(); _analysisLoadCts = null; @@ -42,10 +56,6 @@ private void AnalysisWindow_Closed(object? sender, EventArgs e) private void SignalList_AnalysisSelectionChanged(object sender, SelectionChangedEventArgs e) { UpdateAnalysisAvailability(); - - // P1D.2D Phasor is a record-level Voltage + Current workstation and no longer depends on - // the selected signal row. Harmonics remains a selected-analog-channel workflow until its - // own parity slice lands. if (_analysisMode == AnalysisMode.Harmonics && _activeSignal is not { IsAnalog: true }) { SetAnalysisMode(AnalysisMode.Waveform); @@ -53,21 +63,31 @@ private void SignalList_AnalysisSelectionChanged(object sender, SelectionChanged } if (_analysisMode == AnalysisMode.Harmonics) - _ = Dispatcher.InvokeAsync(async () => await RefreshNativeAnalysisAsync().ConfigureAwait(true)); + { + 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); - private void PhasorCursor1_Click(object sender, RoutedEventArgs e) => SetPhasorReferenceCursor(ComtradeDisturbanceCursor.Cursor1); - private void PhasorCursor2_Click(object sender, RoutedEventArgs e) => SetPhasorReferenceCursor(ComtradeDisturbanceCursor.Cursor2); - private void SetPhasorReferenceCursor(ComtradeDisturbanceCursor cursor) + // 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) { - _phasorReferenceCursor = cursor; - ApplyPhasorCursorVisuals(); - if (_analysisMode == AnalysisMode.Phasor) - _ = RefreshNativeAnalysisAsync(); + _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) @@ -77,23 +97,37 @@ private void SetAnalysisMode(AnalysisMode mode) 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 = mode == AnalysisMode.Phasor ? Visibility.Visible : Visibility.Collapsed; - NavigationTextBlock.Text = mode switch - { - AnalysisMode.Phasor => "Dual Voltage / Current diagrams • choose C1 or C2 • native one-cycle DFT • common phase reference", - AnalysisMode.Harmonics => "C1 = harmonic reference • full-cycle native DFT • click a bar for details", - _ => "Wheel scrolls tracks • Ctrl+wheel zooms time • drag plot pans • drag C1/C2 measures • right-click places C2" - }; + PhasorReferencePanel.Visibility = Visibility.Collapsed; ApplyAnalysisModeVisuals(); - if (mode != AnalysisMode.Waveform) - _ = RefreshNativeAnalysisAsync(); + 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() @@ -102,17 +136,14 @@ private void UpdateAnalysisAvailability() var selectedAnalog = _activeSignal is { IsAnalog: true }; PhasorModeButton.IsEnabled = hasAnalogRecord; HarmonicsModeButton.IsEnabled = selectedAnalog; - PhasorModeButton.ToolTip = hasAnalogRecord - ? "Show record-level Voltage and Current fundamental RMS phasors at global C1 or C2." + ? "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 - ? "Show native ArdIrec harmonic spectrum at C1 (or the visible Time Signals center when C1 is unset)." - : "Select an analog signal first. C1 on Time Signals becomes the harmonic reference."; - - PhasorCursor1Button.IsEnabled = DisturbanceView.Cursor1Milliseconds.HasValue; - PhasorCursor2Button.IsEnabled = DisturbanceView.Cursor2Milliseconds.HasValue; - ApplyPhasorCursorVisuals(); + ? "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() @@ -120,7 +151,6 @@ private void ApplyAnalysisModeVisuals() ApplyModeButton(WaveformModeButton, _analysisMode == AnalysisMode.Waveform); ApplyModeButton(PhasorModeButton, _analysisMode == AnalysisMode.Phasor); ApplyModeButton(HarmonicsModeButton, _analysisMode == AnalysisMode.Harmonics); - ApplyPhasorCursorVisuals(); } private static void ApplyModeButton(Button button, bool selected) @@ -131,111 +161,131 @@ private static void ApplyModeButton(Button button, bool selected) button.BorderThickness = new Thickness(1); } - private void ApplyPhasorCursorVisuals() + /// + /// 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) { - ApplyCursorButton( - PhasorCursor1Button, - _phasorReferenceCursor == ComtradeDisturbanceCursor.Cursor1, - Color.FromRgb(221, 142, 32), - Color.FromRgb(255, 247, 232)); - ApplyCursorButton( - PhasorCursor2Button, - _phasorReferenceCursor == ComtradeDisturbanceCursor.Cursor2, - Color.FromRgb(36, 172, 211), - Color.FromRgb(235, 249, 253)); + if (_analysisMode == AnalysisMode.Waveform) return; + _analysisScrubDirty = true; + _analysisFinalRequested |= isFinal; + EnsureAnalysisRenderingPump(); } - private static void ApplyCursorButton(Button button, bool selected, Color accent, Color selectedBackground) + private Task RefreshNativeAnalysisAsync() { - button.Foreground = new SolidColorBrush(selected ? accent : Color.FromRgb(94, 111, 132)); - button.Background = new SolidColorBrush(selected ? selectedBackground : Colors.White); - button.BorderBrush = new SolidColorBrush(selected ? accent : Color.FromRgb(203, 216, 231)); - button.BorderThickness = new Thickness(1); + 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 async Task RefreshNativeAnalysisAsync() + private void AnalysisCompositionFrame(object? sender, EventArgs e) { if (_analysisMode == AnalysisMode.Waveform) + { + StopAnalysisRenderingPump(); return; - if (_analysisMode == AnalysisMode.Harmonics && _activeSignal is not { IsAnalog: true }) + } + if (_analysisWorkerRunning || !_analysisScrubDirty) return; - _analysisLoadCts?.Cancel(); - _analysisLoadCts?.Dispose(); - _analysisLoadCts = new CancellationTokenSource(); - var token = _analysisLoadCts.Token; - var preferredCursor = _analysisMode == AnalysisMode.Phasor - ? _phasorReferenceCursor - : ComtradeDisturbanceCursor.Cursor1; - var cursorReference = TryResolveAnalysisCursorFrame(preferredCursor, out var cursorFrame); - var referenceFrame = cursorReference ? cursorFrame : ResolveAnalysisReferenceFrame(preferredCursor); - var timeMs = await TryReadReferenceTimeMillisecondsAsync(referenceFrame, token).ConfigureAwait(true); - if (token.IsCancellationRequested) return; - var triggerMs = ResolveTriggerMilliseconds(); - var relative = timeMs is { } absolute && triggerMs is { } trigger ? absolute - trigger : (double?)null; - var referenceName = cursorReference ? CursorName(preferredCursor) : "visible center"; - var referenceTimeText = relative is { } relativeMs - ? ComtradeDisturbanceTimelineMath.FormatRelativeTime(relativeMs) - : timeMs is { } absoluteMs - ? $"{absoluteMs:G7} ms" - : "time unavailable"; - AnalysisReferenceTextBlock.Text = $"Analysis reference: {referenceName} • frame {referenceFrame:N0} • {referenceTimeText}"; + _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 { - if (_analysisMode == AnalysisMode.Phasor) + var token = _analysisLoadCts?.Token ?? CancellationToken.None; + if (request.Mode == AnalysisMode.Phasor) { - PhasorView.ShowMessage("Phasor", "Calculating native Voltage and Current one-cycle phasors…"); - var result = await LoadPhasorWorkspaceAsync(referenceFrame, token).ConfigureAwait(true); - if (token.IsCancellationRequested || _analysisMode != AnalysisMode.Phasor) return; - if (result.VoltageVectors.Count == 0 && result.CurrentVectors.Count == 0) + if (!_phasorFrameCache.TryGetValue(request.ReferenceFrame, out var phasor)) { - PhasorView.ShowMessage("Phasor", "The selected reference does not contain a complete analyzable Voltage or Current cycle."); - StatusTextBlock.Text = $"Native ArdIrec phasor analysis • {referenceName} • no valid Voltage/Current vectors."; - return; + phasor = await LoadPhasorWorkspaceAsync(request.ReferenceFrame, token).ConfigureAwait(true); + RememberPhasor(request.ReferenceFrame, phasor); } - - var detail = $"{referenceTimeText} • frame {referenceFrame:N0} • full-cycle DFT • RMS magnitude • common phase reference"; - PhasorView.ShowPhasors( - referenceName.ToUpperInvariant(), - detail, - result.VoltageVectors, - result.CurrentVectors); - StatusTextBlock.Text = $"Native ArdIrec phasor workstation • {referenceName} • frame {referenceFrame:N0} • " + - $"{result.VoltageVectors.Count} voltage + {result.CurrentVectors.Count} current vector(s)"; - return; + var timeMs = await TryReadReferenceTimeMillisecondsAsync(request.ReferenceFrame, token).ConfigureAwait(true); + if (!IsRequestCurrent(request)) return; + PresentPhasor(request.ReferenceFrame, timeMs, phasor); } - - var signal = _activeSignal!; - HarmonicsView.ShowMessage("Harmonics", "Calculating native harmonic spectrum…"); - var spectrum = await LoadHarmonicsAsync(signal, referenceFrame, token).ConfigureAwait(true); - if (token.IsCancellationRequested || _analysisMode != AnalysisMode.Harmonics) return; - if (!spectrum.Valid || spectrum.Bins.Count == 0) + else if (request.ChannelIndex is { } channel && _activeSignal is { IsAnalog: true } signal) { - HarmonicsView.ShowMessage("Harmonics", "The selected reference does not contain a valid full-cycle harmonic window."); - return; + 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); } - - 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()); - var harmonicSubtitle = BuildAnalysisSubtitle(metadata, referenceFrame, spectrum.Bins.Count, - $"orders H1…H{spectrum.Bins[^1].Order}"); - HarmonicsView.ShowSpectrum("Harmonic spectrum", harmonicSubtitle, display); - StatusTextBlock.Text = $"Native ArdIrec harmonics • {referenceName} • THD {spectrum.ThdPercent:G5}% • " + - (spectrum.DominantOrder > 1 - ? $"dominant H{spectrum.DominantOrder} {spectrum.DominantPercent:G4}%" - : "no meaningful distortion harmonic"); } catch (OperationCanceledException) { @@ -245,33 +295,123 @@ private async Task RefreshNativeAnalysisAsync() } catch (Exception ex) { - if (_analysisMode == AnalysisMode.Phasor) - PhasorView.ShowMessage("Phasor analysis failed", ex.Message); - else if (_analysisMode == AnalysisMode.Harmonics) - HarmonicsView.ShowMessage("Harmonic analysis failed", ex.Message); - StatusTextBlock.Text = $"Native COMTRADE analysis failed: {ex.Message}"; + 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 TryResolveAnalysisCursorFrame(ComtradeDisturbanceCursor cursor, out ulong frame) + private bool IsRequestCurrent(AnalysisScrubRequest request) { - frame = 0; - var milliseconds = cursor == ComtradeDisturbanceCursor.Cursor1 - ? DisturbanceView.Cursor1Milliseconds - : DisturbanceView.Cursor2Milliseconds; - return milliseconds is { } value && TryResolveDisturbanceFrameAtMilliseconds(value, out frame); + if (request.Generation != _analysisGeneration || request.Mode != _analysisMode) + return false; + return request.Mode != AnalysisMode.Harmonics || + (_activeSignal is { IsAnalog: true } signal && request.ChannelIndex == signal.Index); } - private ulong ResolveAnalysisReferenceFrame(ComtradeDisturbanceCursor preferredCursor) + private void PresentPhasor(ulong referenceFrame, double? timeMs, ComtradePhasorWorkspaceResult result) { - if (TryResolveAnalysisCursorFrame(preferredCursor, out var cursorFrame)) - return cursorFrame; - if (TryResolveDisturbanceViewportCenterFrame(out var centerFrame)) - return centerFrame; - - var total = _record.Info.FrameCount; - if (total == 0) return 0; - return (total - 1) / 2; + 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) @@ -318,24 +458,16 @@ private async Task LoadPhasorWorkspaceAsync( ? semantics!.PhaseRole : ComtradePhasorWorkspaceMath.PhaseRoleFromCanonicalName(fallbackPhase); descriptors.Add(new ComtradePhasorChannelDescriptor( - checked((uint)index), - role, - phaseRole, - channel.Id, + checked((uint)index), role, phaseRole, channel.Id, ComtradePhasorWorkspaceMath.CanonicalPhaseName(phaseRole, fallbackPhase), - channel.Circuit, - channel.Units)); + channel.Circuit, channel.Units)); } - var voltageChannels = ComtradePhasorWorkspaceMath.SelectRoleSet( - descriptors, - ComtradePhasorWorkspaceMath.RoleVoltage); - var currentChannels = ComtradePhasorWorkspaceMath.SelectRoleSet( - descriptors, - ComtradePhasorWorkspaceMath.RoleCurrent); - var voltageVectors = ReadPhasorVectors(voltageChannels, referenceFrame, token); - var currentVectors = ReadPhasorVectors(currentChannels, referenceFrame, token); - return new ComtradePhasorWorkspaceResult(voltageVectors, currentVectors); + 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 @@ -393,9 +525,6 @@ private static string BuildAnalysisSubtitle( return $"{context} • reference frame {referenceFrame:N0} • {itemCount} {suffix}"; } - private static string CursorName(ComtradeDisturbanceCursor cursor) - => cursor == ComtradeDisturbanceCursor.Cursor1 ? "C1" : "C2"; - private static string NormalizePhase(string phase, string id) { var direct = (phase ?? string.Empty).Trim().ToUpperInvariant(); @@ -412,6 +541,15 @@ private static string NormalizePhase(string phase, string id) 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 index 18d4c21a0..bd203ae7b 100644 --- a/ComtradeWorkspaceWindow.Disturbance.cs +++ b/ComtradeWorkspaceWindow.Disturbance.cs @@ -189,11 +189,19 @@ private async Task ReloadDisturbanceAsync( _disturbanceLoadCts?.Dispose(); _disturbanceLoadCts = new CancellationTokenSource(); var token = _disturbanceLoadCts.Token; - var selected = _disturbanceVisibleSignals.Take(MaxVisibleDisturbanceTracks).ToArray(); + 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"; @@ -213,6 +221,7 @@ private async Task ReloadDisturbanceAsync( _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); @@ -221,9 +230,6 @@ private async Task ReloadDisturbanceAsync( DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); _disturbanceInitialFocusApplied = true; - // A full-record 4096-bucket envelope can be too sparse for an eight-cycle opening - // view. Use timestamp->source-frame identity from that overview to reload a buffered - // trigger neighborhood, then keep the same trigger-focused time window. if (result.SourceViewport.FrameCount > ExactSignalFrameLimit && TryBuildSourceViewportForTimeWindow( DisturbanceView.ViewStartMilliseconds, @@ -232,7 +238,7 @@ private async Task ReloadDisturbanceAsync( triggerViewport.FrameCount > 0 && triggerViewport.FrameCount < result.SourceViewport.FrameCount) { - StatusTextBlock.Text = "Refining trigger neighborhood from native source frames…"; + StatusTextBlock.Text = "Refining trigger neighborhood from source frames…"; await ReloadDisturbanceAsync(triggerViewport, initialLoad: false).ConfigureAwait(true); DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); return; @@ -247,8 +253,10 @@ private async Task ReloadDisturbanceAsync( 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); - StatusTextBlock.Text = $"Time Signals • {result.Tracks.Count} tracks • {result.SourceViewport.FrameCount:N0} source frames" + - (result.SourceViewport.FrameCount <= ExactSignalFrameLimit ? " • exact native samples" : " • bounded native overview"); + 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) { @@ -260,6 +268,7 @@ private async Task ReloadDisturbanceAsync( { DisturbanceView.ShowMessage(ex.Message); StatusTextBlock.Text = $"Time Signals load failed: {ex.Message}"; + ComtradeDiagnosticQueue.TryEnqueue("P1D5.TimeSignals", "TIMESIGNALS_LOAD_FAILURE", $"viewport={requestedViewport}", ex); } } @@ -299,12 +308,23 @@ private DisturbanceLoadResult BuildDisturbanceTracks( 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, - BuildTrackSubtitle(metadata.Phase, metadata.Circuit), + BuildP1D5TrackSubtitle(metadata), metadata.Units, false, - _record.ReadAnalog(signal.Index, viewport.StartFrame, count), + values, null, timestamps, ResolveSignalColor(metadata.Phase, signal.Title, false), @@ -338,26 +358,54 @@ private DisturbanceLoadResult BuildDisturbanceTracks( token.ThrowIfCancellationRequested(); if (signal.IsAnalog) { - var envelope = ComtradeRangeDecimator.BuildAnalogEnvelope( - source, - signal.Index, - viewport.StartFrame, - viewport.FrameCount, - FullRecordAnalogBuckets, - cancellationToken: token); - var series = ComtradeDecimatedSeriesBuilder.BuildAnalog(envelope); var metadata = _record.AnalogChannels[checked((int)signal.Index)]; - loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( - signal.Title, - BuildTrackSubtitle(metadata.Phase, metadata.Circuit), - metadata.Units, - false, - series.Values, - null, - series.Timestamps, - ResolveSignalColor(metadata.Phase, signal.Title, false), - PreserveAllPoints: true, - SourceFrames: series.SourceFrames))); + 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 { @@ -400,6 +448,15 @@ private DisturbanceLoadResult BuildDisturbanceTracks( 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]; @@ -432,10 +489,6 @@ private static IReadOnlyList BuildReducedDigital { if (transitionSet.Transitions.Count <= 1) return Array.Empty(); - - // Every retained item after the initial state is an actual binary transition. Adaptive - // sampling may omit intermediate transitions, so infer the immediate before-state as the - // opposite raw value instead of comparing adjacent retained samples. return transitionSet.Transitions .Skip(1) .Select(transition => @@ -498,6 +551,7 @@ private void DigitalEventGrid_SelectionChanged(object sender, SelectionChangedEv { 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}."; } @@ -519,6 +573,7 @@ private async void DisturbanceReset_Click(object sender, RoutedEventArgs e) } DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); _disturbanceInitialFocusApplied = true; + QueueP1D5CursorMeasurements(); } private async void DisturbanceFullRecord_Click(object sender, RoutedEventArgs e) @@ -539,9 +594,9 @@ private void DisturbanceView_NavigationChanged(object? sender, ComtradeDisturban private async void DisturbanceView_PreviewMouseWheel(object sender, MouseWheelEventArgs e) { if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) - return; // Normal wheel belongs to the vertical track ScrollViewer. + return; if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) - return; // Small records use the control's in-memory Ctrl+wheel zoom. + return; var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; var plotFraction = DisturbanceView.PlotFractionAt(e.GetPosition(DisturbanceView).X); @@ -570,9 +625,6 @@ private async void DisturbanceView_PanRequested(object? sender, ComtradeDisturba { if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) return; - - // If the in-memory view still has margin inside the loaded native range, local panning is - // sufficient. Reload source frames only when the gesture reaches a loaded-range edge. const double epsilon = 1e-6; if (DisturbanceView.ViewStartMilliseconds > DisturbanceView.FullStartMilliseconds + epsilon && DisturbanceView.ViewEndMilliseconds < DisturbanceView.FullEndMilliseconds - epsilon) @@ -591,7 +643,10 @@ private async void DisturbanceView_CursorChanged(object? sender, ComtradeDisturb 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(); @@ -616,11 +671,16 @@ private async void DisturbanceView_CursorChanged(object? sender, ComtradeDisturb _nativeGate.Release(); } - if (token.IsCancellationRequested || edge is not { Valid: true }) return; + 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}"; @@ -642,11 +702,9 @@ private ComtradeSourceViewport CurrentDisturbanceViewport() : ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); private double? ResolveTriggerMilliseconds() - { - return ComtradeTimeMath.TryGetTriggerOffsetMilliseconds(_record.Info.StartTime, _record.Info.TriggerTime, out var trigger) + => ComtradeTimeMath.TryGetTriggerOffsetMilliseconds(_record.Info.StartTime, _record.Info.TriggerTime, out var trigger) ? trigger : null; - } private bool TryResolveDisturbanceCursorFrame(out ulong frame) { @@ -666,8 +724,7 @@ private bool TryResolveDisturbanceFrameAtMilliseconds(double milliseconds, out u 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 searchTimestamps = count == timestamps.Length ? timestamps : timestamps.Take(count).ToArray(); - var index = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetRaw); + 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; @@ -687,9 +744,8 @@ private bool TryBuildSourceViewportForTimeWindow( 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 searchTimestamps = count == timestamps.Length ? timestamps : timestamps.Take(count).ToArray(); - var startIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetStartRaw); - var endIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetEndRaw); + 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); 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..45a737adf --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5CursorMeasurements.cs @@ -0,0 +1,380 @@ +using System.Globalization; +using System.Threading; +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 double P1D5TrackTopMargin = 14.0; + private const double P1D5AnalogTrackHeight = 92.0; + private const double P1D5DigitalTrackHeight = 38.0; + private const double P1D5TrackGap = 5.0; + + [Flags] + private enum P1D5MeasurementTargets + { + None = 0, + Cursor1 = 1, + Cursor2 = 2, + Both = Cursor1 | Cursor2 + } + + private readonly Dictionary _p1d5CursorReadoutControls = 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 _p1d5InFlightMeasurementTargets; + 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); + } + + private void P1D5DisturbanceCursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + if (_analysisMode != AnalysisMode.Waveform) + return; + QueueP1D5CursorMeasurements(e.Cursor == ComtradeDisturbanceCursor.Cursor1 + ? P1D5MeasurementTargets.Cursor1 + : P1D5MeasurementTargets.Cursor2); + } + + 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; + 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 = P1D5MeasurementTargets.Both) + { + if (_analysisMode != AnalysisMode.Waveform || targets == P1D5MeasurementTargets.None || + _p1d5VisibleAnalogTrackOrder.Length == 0 || !_record.Supports(ArdIrecNativeBridge.CapCursorMeasurement)) + return; + + // If a newer cursor move invalidates a worker already in flight, carry that worker's target + // into the replacement request. This preserves eventual values for both cursors while the + // normal scrub path reads only the cursor that actually moved. + 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 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; + }); + } + 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); + } + 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); + } + + 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 (result.Targets.HasFlag(P1D5MeasurementTargets.Cursor1)) + controls.Cursor1.Text = FormatP1D5CursorValue("C1", row.Cursor1); + if (result.Targets.HasFlag(P1D5MeasurementTargets.Cursor2)) + 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 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); +} diff --git a/ComtradeWorkspaceWindow.P1D5Display.cs b/ComtradeWorkspaceWindow.P1D5Display.cs new file mode 100644 index 000000000..979b9dbd4 --- /dev/null +++ b/ComtradeWorkspaceWindow.P1D5Display.cs @@ -0,0 +1,222 @@ +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 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. Keep the retained waveform data/geometry, update only frame metadata and + /// representation-dependent native readouts/analysis. + /// + private async Task RefreshP1D5RepresentationAsync() + { + if (_p1d5PresentationRefreshRunning) + return; + _p1d5PresentationRefreshRunning = true; + try + { + InvalidateP1D5AnalysisPresentationCaches(); + DisturbanceView.SetAnalogRepresentationLabel(P1D5RepresentationLabel); + + if (_p1d5LocusActive) + { + await RefreshP1D5LocusStaticAsync(forceReopen: false).ConfigureAwait(true); + QueueP1D5LocusCursorRefresh(); + } + else + { + QueueP1D5CursorMeasurements(); + if (_analysisMode != AnalysisMode.Waveform) + 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 = false; + for (var index = 0; index < _record.AnalogChannels.Count; index++) + { + var semantics = P1D5AnalogSemantics(checked((uint)index)); + if (semantics is { HasValidTransformerRatio: true }) + { + hasConvertibleAnalog = true; + break; + } + } + 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 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) + { + var semantics = P1D5AnalogSemantics(channelIndex); + if (semantics is not { HasValidTransformerRatio: true }) + return 1.0; + + var scale = _p1d5ValueRepresentation == P1D5RepresentationPrimary + ? semantics.ScaleToPrimary + : semantics.ScaleToSecondary; + return double.IsFinite(scale) && Math.Abs(scale) > 1e-15 ? 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 467690abc..e2cca1f0a 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -3,10 +3,22 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ArIED61850Tester.Controls" Title="ARSAS — COMTRADE Workspace" - Width="1280" Height="800" MinWidth="980" MinHeight="620" + Width="1360" Height="960" MinWidth="1040" MinHeight="700" WindowStartupLocation="CenterOwner" + Loaded="P1D5Workspace_Loaded" Background="#F3F6FA" FontFamily="Segoe UI"> + + + + + @@ -23,22 +35,10 @@ - - - - - - - - - - - - - - - - + + + + @@ -92,8 +92,8 @@ -