RE: resolve R1.8/R1.9 analog/state summary via request+response capture

Captured the native StartQuery2 pRequestBuff and the GetNextQueryResultBuffer2
response (instrument-wcf-writemessage + chained instrument-wcf-readmessage) and
decoded both against AnalogSummaryHistory SQL ground truth. Conclusion: the rich
multi-aggregate analog/state summary struct is NOT delivered over the 2020 WCF
binary protocol — the response is the ordinary version-9 row buffer the existing
aggregate parser already handles, carrying one value per cycle selected by
RetrievalMode (QueryType 5-8), not ValueSelector (inert on this path). So
"analog summary" == the existing ReadAggregateAsync; no new src/ code warranted.

Tooling (tools/ + scripts/ only, nothing in src/):
- NativeTraceHarness: drive summary knobs via --value-selector /
  --aggregation-type / --max-states (uint16) / --filter
- Capture-SummaryRequest.ps1: repeatable instrument+stage+matrix capture,
  -WithResponse chains the ReadMessage hook
- decode-summary-capture.py: StartQuery2 request diff vs baseline
- decode-summary-response.py: response decode vs SQL ground truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-06-20 17:01:42 -04:00
parent 362fcb0ef4
commit 1a7519c803
5 changed files with 531 additions and 38 deletions
+150
View File
@@ -0,0 +1,150 @@
<#
.SYNOPSIS
Captures the native AVEVA client's StartQuery2 request bytes for analog/state
summary queries (HCAL roadmap R1.8/R1.9) so the managed SDK's summary request
shape can be decoded against ground truth instead of guessed.
.DESCRIPTION
Drives the .NET-Framework NativeTraceHarness against the live Historian with an
IL-rewritten copy of aahClientManaged.dll whose ClientMessageEncoder.WriteMessage
is instrumented to log every outgoing MDAS body (the same pipeline that produced
every other proven request shape). For each candidate HistoryQueryArgs config it
writes a per-config NDJSON capture under
artifacts/reverse-engineering/instrumented-wcf-writemessage-summary/ (gitignored).
The default matrix is:
- baseline-full : RetrievalMode=Full (the known-good non-summary request)
- analog-avg : RetrievalMode=Cyclic + ValueSelector=Average + Resolution
- analog-min : RetrievalMode=Cyclic + ValueSelector=Minimum + Resolution
- analog-agg-avg : RetrievalMode=Cyclic + AggregationType=Average + Resolution
- state-summary : RetrievalMode=Cyclic + MaxStates>0 + Resolution
Diff any candidate against baseline-full (scripts/decode-summary-capture.py) to read
off the exact QueryType / SummaryType / AutoSummaryParameters bytes the native client
sets for a summary, then implement the managed request against that.
.NOTES
Artifacts are diagnostic. Sanitize before copying anything into docs/ — never commit
raw capture NDJSON, credentials, hostnames, or customer tag names.
#>
[CmdletBinding()]
param(
[string]$ServerName = "localhost",
[int]$TcpPort = 32568,
# SysTimeSec is the local data-bearing system tag (OtOpcUaParityTest_001.Counter is stale/empty).
[string]$TagName = "SysTimeSec",
[int]$LookbackMinutes = 240,
[int]$MaxRows = 4,
# 1-hour summary cycle in 100ns ticks (1h = 36,000,000,000 ticks).
[uint64]$ResolutionTicks = 36000000000,
[string]$Configuration = "Debug",
# Restrict the run to a single named config from the matrix (default: run all).
[string]$OnlyConfig = "",
# Also instrument ReadMessage so each capture includes the incoming WCF response bodies
# (the GetNextQueryResultBuffer2 pResultBuff summary rows). Decoded by decode-summary-response.py.
[switch]$WithResponse
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$reProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseEngineering\AVEVA.Historian.ReverseEngineering.csproj"
$harnessProj = Join-Path $repoRoot "tools\AVEVA.Historian.NativeTraceHarness\AVEVA.Historian.NativeTraceHarness.csproj"
$instrProj = Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\AVEVA.Historian.ReverseInstrumentation.csproj"
$captureDir = Join-Path $repoRoot "artifacts\reverse-engineering\instrumented-wcf-writemessage-summary"
$currentCopy = Join-Path $captureDir "current-copy"
$instrDll = Join-Path $captureDir "aahClientManaged.dll"
Write-Host "== Building tooling ($Configuration) ==" -ForegroundColor Cyan
dotnet build $reProj -c $Configuration --nologo -v q | Out-Null
dotnet build $instrProj -c $Configuration --nologo -v q | Out-Null
dotnet build $harnessProj -c $Configuration --nologo -v q | Out-Null
$instrSourceDll = Get-ChildItem -Recurse (Join-Path $repoRoot "tools\AVEVA.Historian.ReverseInstrumentation\bin\$Configuration") `
-Filter "AVEVA.Historian.ReverseInstrumentation.dll" | Select-Object -First 1 -ExpandProperty FullName
if (-not $instrSourceDll) { throw "ReverseInstrumentation.dll not found under bin\$Configuration." }
Write-Host "== Instrumenting WriteMessage$(if ($WithResponse) { ' + ReadMessage' }) ==" -ForegroundColor Cyan
New-Item -ItemType Directory -Force -Path $captureDir | Out-Null
if ($WithResponse) {
# Chain via a distinct intermediate file (reading+writing the same path drops the second
# hook on the mixed-mode native image). Final dll carries both hooks with distinct Phase
# strings: WCF.WriteMessage.Body and WCF.ReadMessage.Body.
$writeOnly = Join-Path $captureDir "aahClientManaged.write.dll"
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $writeOnly | Out-Null
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-readmessage $writeOnly $instrDll | Out-Null
} else {
dotnet run --no-build -c $Configuration --project $reProj -- `
instrument-wcf-writemessage (Join-Path $repoRoot "current\aahClientManaged.dll") $instrDll | Out-Null
}
Write-Host "== Staging current-copy ==" -ForegroundColor Cyan
# Mirror current/ into current-copy, then overwrite the managed dll with the instrumented
# build and drop the strong-named logger assembly alongside it so the injected call binds.
robocopy (Join-Path $repoRoot "current") $currentCopy /MIR /NJH /NJS /NDL /NP /NC /NS | Out-Null
Copy-Item -Force $instrDll (Join-Path $currentCopy "aahClientManaged.dll")
Copy-Item -Force $instrSourceDll (Join-Path $currentCopy "AVEVA.Historian.ReverseInstrumentation.dll")
# Candidate matrix: name + harness arg list. Summary configs all use Cyclic + a resolution;
# the differentiator is which summary knob is set.
$matrix = @(
@{ Name = "baseline-full"; Args = @("--retrieval-mode", "Full") },
@{ Name = "analog-avg"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Average", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "analog-min"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Minimum", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "analog-max"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Maximum", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "analog-integral"; Args = @("--retrieval-mode", "Cyclic", "--value-selector", "Integral", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "mode-integral"; Args = @("--retrieval-mode", "Integral", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "mode-twavg"; Args = @("--retrieval-mode", "TimeWeightedAverage", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "analog-agg-avg"; Args = @("--retrieval-mode", "Cyclic", "--aggregation-type", "Average", "--resolution-ticks", "$ResolutionTicks") },
@{ Name = "state-summary"; Args = @("--retrieval-mode", "Cyclic", "--max-states", "10", "--resolution-ticks", "$ResolutionTicks") }
)
if ($OnlyConfig) { $matrix = $matrix | Where-Object { $_.Name -eq $OnlyConfig } }
if (-not $matrix) { throw "No matrix entry named '$OnlyConfig'." }
$harnessDll = Join-Path $currentCopy "aahClientManaged.dll"
$summary = @()
foreach ($cfg in $matrix) {
$name = $cfg.Name
$capturePath = Join-Path $captureDir "summary-capture-$name-latest.ndjson"
if (Test-Path $capturePath) { Remove-Item -Force $capturePath }
$env:AVEVA_HISTORIAN_RE_CAPTURE = $capturePath
Write-Host "== Capturing: $name ==" -ForegroundColor Green
$harnessArgs = @(
"--scenario", "history",
"--server-name", $ServerName,
"--tcp-port", "$TcpPort",
"--tag", $TagName,
"--lookback-minutes", "$LookbackMinutes",
"--max-rows", "$MaxRows",
"--current-dir", $currentCopy,
"--managed-dll-path", $harnessDll
) + $cfg.Args
# Don't let a single config that errors (e.g. state summary on an analog tag) abort the
# whole matrix, and don't treat dotnet's stderr noise as a terminating error.
try {
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& dotnet run --no-build -c $Configuration --project $harnessProj -- @harnessArgs 2>&1 | Out-Null
} catch {
Write-Host " (config '$name' raised: $($_.Exception.Message))" -ForegroundColor Yellow
} finally {
$ErrorActionPreference = $prevEap
}
$recCount = if (Test-Path $capturePath) { (Get-Content $capturePath | Where-Object { $_.Trim() }).Count } else { 0 }
Write-Host " -> $recCount records -> $capturePath"
$summary += [pscustomobject]@{ Config = $name; Records = $recCount; Capture = $capturePath }
}
Remove-Item Env:\AVEVA_HISTORIAN_RE_CAPTURE -ErrorAction SilentlyContinue
Write-Host "`n== Capture summary ==" -ForegroundColor Cyan
$summary | Format-Table -AutoSize
Write-Host "Decode with: python scripts\decode-summary-capture.py" -ForegroundColor Cyan