igneum/tools/ca3-pc1-amd/pc1-amd-watts.ps1

273 lines
21 KiB
PowerShell

# Counter ASIC 3.0, PC 1 AMD job 1b (6 October 2026, revised after Ember run 6): the two watts cells job 1 left owed,
# taken the way main trusts. Main's facts (Ember run 6, PC 1 through the installed app): the 9070 XT's watts are fine
# through the APP's path (the engine's telemetry 363 of 363 nonzero samples, /api/state 456 of 456); a direct helper
# call outside the app is not trusted by itself. So:
# 1. the CONTROL row (item 1's AMD per-joule row, class v3) is taken with the card ON in the app, mining the live class
# v3 program as it does now: /api/state polled at 1 Hz for 90 s, the gfx1201 card's watts (power_w, from the app's
# own helper at -l 5, so the value moves every 5 s) and its MH/s wall (hash_now, the app's last interval; hash_avg
# printed beside it), the key read from settings.json: `RESULT APPROW class=v3 mhs_wall= watts_mean= watts_min=
# watts_max= samples= uj=`. The helper sampler (a second copy of igneum-gpu-telemetry.exe at -l 1 through the
# environment-fed wrapper) runs BESIDE it over the same 90 s: `RESULT sampler_vs_app helper_mean= app_mean= ratio=`.
# 2. the CANDIDATE row (sh256x27, the card ALONE through the kit worker, which /api/state cannot see because the card is
# off in the app): the helper sampler is its only source, printed with the ratio as the calibration (`RESULT LADDER
# ... watts_source=adlx calibration_ratio=`); a ratio outside 0.95 to 1.05 prints the candidate's watts as owed with
# the reason. The sampler proves itself in the first 12 s (first three raw lines, a 9070 XT line with watts) before
# anything else, else `RESULT watts error=<why>` and no rows.
# The card switch: every key form of every settings.json gfx1201 entry posted through POST api/cards, quiet confirmed by
# the process list (the opencl worker carrying the card's --device index gone), each entry restored with ITS OWN enabled
# flag and identities in finally (derived forms first, the key as written last). Plain signed `run` job (NOT
# --stop-miners): the 5090 and the 4070 keep mining. Never quits, pauses, resumes or updates the app; never writes
# settings.json. Lines: `RESULT APPROW ...`, `RESULT sampler_vs_app ...`, `RESULT LADDER ...`, `RESULT G1 ...` for the
# candidate, a `SUMMARY {json}` line. Read back with `node tools/jobs.mjs <job id>`.
$ErrorActionPreference = 'Continue'
$jobName = 'pc1-amd-watts'
$kitId = 'fetch-ca3-pc1-amd-20261006'
$started = Get-Date
function Stamp { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
function Summary([string] $status, [hashtable] $extra) {
$o = [ordered]@{ job = $jobName; status = $status; duration_s = [int]((Get-Date) - $started).TotalSeconds; finished_at = (Stamp) }
foreach ($k in $extra.Keys) { $o[$k] = $extra[$k] }
'SUMMARY ' + ($o | ConvertTo-Json -Compress -Depth 4)
}
$expectedFp = '3d2e8245cc084d07' # sh256x27 at base nonce 0, 2^24 (the Mac, the 5090, PC 1 job 1)
$appWindowS = 90
$candBatches = 100 # 2^24 dispatches at the 19.3 MH/s job 1 measured: about 87 s
"RESULT start $(Stamp) job=$jobName machine=$env:COMPUTERNAME app_version=$env:IGNEUM_APP_VERSION"
$jobs = Split-Path $env:IGNEUM_JOB_DIR
$kit = Join-Path $jobs $kitId
if (-not (Test-Path $kit)) { "RESULT error kit missing at $kit (the fetch job $kitId runs first; republish it after any app update)"; Summary 'failed' @{ error = 'kit missing' }; exit 2 }
$exe = Join-Path $kit 'bin\igneum-worker-opencl.exe'
$packDir = Join-Path $kit 'packs\sh256x27'
if (-not (Test-Path $exe)) { "RESULT error worker missing at $exe"; Summary 'failed' @{ error = 'worker missing' }; exit 2 }
if (-not (Test-Path $packDir)) { "RESULT error pack missing at $packDir"; Summary 'failed' @{ error = 'pack missing' }; exit 2 }
"RESULT worker kit $exe sha256 $((Get-FileHash -Algorithm SHA256 $exe).Hash.ToLower()) bytes $((Get-Item $exe).Length)"
$job = $env:IGNEUM_JOB_DIR; if (-not $job) { $job = Join-Path $env:TEMP 'igneum-ca3-pc1-amd' }; New-Item -ItemType Directory -Force -Path $job | Out-Null
$inst = @("$env:LOCALAPPDATA\Programs\Igneum Miner", "$env:ProgramFiles\Igneum Miner") | Where-Object { Test-Path (Join-Path $_ 'igneum-app.exe') } | Select-Object -First 1
$appDir = if ($env:IGNEUM_APP_DIR) { $env:IGNEUM_APP_DIR } else { Join-Path $env:LOCALAPPDATA 'igneum\app' }
$urlFile = Join-Path $appDir 'app.url'
$base = $null
if (Test-Path $urlFile) { $base = (Get-Content -LiteralPath $urlFile -Raw).Trim().TrimEnd('/') }
"RESULT app install=$inst app_dir=$appDir url_present=$([bool]$base)"
if (-not $base) { "RESULT error no app.url: the control row needs the app's /api/state"; Summary 'failed' @{ error = 'no app.url' }; exit 2 }
$listExe = $exe
if ($inst -and (Test-Path (Join-Path $inst 'igneum-worker-opencl.exe'))) { $listExe = Join-Path $inst 'igneum-worker-opencl.exe' }
$list = @(& $listExe --list 2>&1 | ForEach-Object { "$_" })
$list | ForEach-Object { "RESULT list $_" }
$dev = $null
foreach ($l in $list) { if ($l -match '^\s*\[(\d+)\].*gfx1201' -and $l -notmatch 'dup') { $dev = [int]$Matches[1]; break } }
if ($null -eq $dev) { "RESULT error no gfx1201 device in --list (the eGPU is off the bus: every row OWED)"; Summary 'failed' @{ error = 'no gfx1201' }; exit 2 }
"RESULT device $dev gfx1201 (list from $listExe)"
# ---- every settings.json entry for the card (READ ONLY), each with its own settings and its key forms ----
$entries = @()
try {
$sj = Get-Content -LiteralPath (Join-Path $appDir 'settings.json') -Raw | ConvertFrom-Json
foreach ($prop in $sj.cards.PSObject.Properties) {
if ($prop.Name -like 'amd:*' -and $prop.Name -match 'gfx1201') {
$parts = $prop.Name -split ':'
$forms = @()
if ($parts.Count -ge 3) { $forms += ($parts[0] + ':' + $parts[1]); $forms += ($parts[0] + ':' + $parts[2]) }
$ident = 1; if ($prop.Value.identities) { $ident = [int] $prop.Value.identities }
$entries += [pscustomobject]@{ key = $prop.Name; forms = $forms; enabled = [bool] $prop.Value.enabled; identities = $ident }
"RESULT card settings_key=$($prop.Name) enabled=$([bool] $prop.Value.enabled) identities=$ident derived_forms=[$($forms -join ' | ')]"
}
}
} catch { "RESULT settings_read_failed $($_.Exception.Message)" }
if ($entries.Count -eq 0) { "RESULT card none in settings.json matching amd:*gfx1201*: no switch possible" }
function PostCards([bool] $enabled) {
foreach ($pass in @('derived', 'written')) {
foreach ($e in $entries) {
$keys = if ($pass -eq 'derived') { $e.forms } else { @($e.key) }
$on = if ($enabled) { $e.enabled } else { $false }
foreach ($k in $keys) {
$body = @{ cards = @(@{ key = $k; enabled = $on; identities = $e.identities }) } | ConvertTo-Json -Depth 5
try { Invoke-RestMethod -Uri "$base/api/cards" -Method POST -Body $body -ContentType 'application/json' -TimeoutSec 10 | Out-Null; "RESULT cards key=$k enabled=$on identities=$($e.identities) posted (from entry $($e.key))" } catch { "RESULT cards key=$k enabled=$on error=$($_.Exception.Message)" }
}
}
}
}
function Workers { @(Get-CimInstance Win32_Process -Filter "Name = 'igneum-worker-opencl.exe' OR Name = 'igneum-worker-cuda.exe'" -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Name):$($_.ProcessId):[$($_.CommandLine -replace '\s+', ' ')]" }) }
function CardWorkers { @(Get-CimInstance Win32_Process -Filter "Name = 'igneum-worker-opencl.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match "--device\s+$dev(\s|$)" } | ForEach-Object { $_.ProcessId }) }
function StateCard {
try {
$st = Invoke-RestMethod -Uri "$base/api/state" -Method GET -TimeoutSec 5
$cards = $st.mining.cards; if (-not $cards) { $cards = $st.cards }
return @($cards | Where-Object { $_.vendor -eq 'amd' -and ("$($_.key)" -match 'gfx1201') -and $_.state -ne 'off' } | Select-Object -First 1)
} catch { return @() }
}
"RESULT workers_before $(Stamp) $((Workers) -join ' ')"
# ---- the AMD helper: one in-process sample first ----
$tele = $null
if ($inst -and (Test-Path (Join-Path $inst 'igneum-gpu-telemetry.exe'))) { $tele = Join-Path $inst 'igneum-gpu-telemetry.exe' }
if (-not $tele) { $found = Get-ChildItem -Path (Join-Path $appDir 'jobs') -Recurse -Filter 'igneum-gpu-telemetry.exe' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'amd-kit' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($found) { $tele = $found.FullName } }
if (-not $tele) { "RESULT watts error=no igneum-gpu-telemetry.exe in the install folder or under an amd-kit job folder"; Summary 'failed' @{ error = 'no helper' }; exit 2 }
$one = @(& $tele 2>&1 | ForEach-Object { "$_" })
$one | ForEach-Object { "RESULT tele_once $_" }
$line9070 = $one | Where-Object { $_ -match '^amd \d+ .*name "[^"]*9070[^"]*" watts (\S+) ' } | Select-Object -First 1
if (-not $line9070 -or $line9070 -notmatch ' watts ([\d.]+) ') { "RESULT watts error=the helper's one-shot sample has no 9070 XT line with a watts number"; Summary 'failed' @{ error = 'no 9070 watts in the one-shot sample' }; exit 2 }
"RESULT tele helper=$tele sha256=$((Get-FileHash -Algorithm SHA256 $tele).Hash.ToLower()) one_shot_watts=$($Matches[1]) (ADLX GPUPower, GPUTotalBoardPower as the fallback)"
$samples = Join-Path $job 'amd-samples.log'
$sampler = $null
$switched = $false
$samplerOk = $false
$cardAlone = $false
$appRows = @()
$appT0 = $null; $appT1 = $null
$cand = $null
try {
# ---- the sampler through the environment-fed wrapper; proven on 12 idle seconds before anything else ----
$wrapper = Join-Path $job 'sampler.ps1'
$wrapperText = @'
$exe = $env:IGNEUM_SAMPLER_EXE
$out = $env:IGNEUM_SAMPLER_OUT
Add-Content -LiteralPath $out -Value ('wrapper start ' + (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fff') + ' exe=' + $exe + ' exists=' + (Test-Path -LiteralPath $exe))
& $exe -l 1 2>&1 | ForEach-Object { Add-Content -LiteralPath $out -Value ((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fff') + ' ' + $_) }
Add-Content -LiteralPath $out -Value ('wrapper end ' + (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fff') + ' exit=' + $LASTEXITCODE)
'@
[IO.File]::WriteAllText($wrapper, $wrapperText, (New-Object System.Text.UTF8Encoding $false))
Remove-Item -LiteralPath $samples -Force -ErrorAction SilentlyContinue
$env:IGNEUM_SAMPLER_EXE = $tele
$env:IGNEUM_SAMPLER_OUT = $samples
$sampler = Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $wrapper + '"')) -WindowStyle Hidden -PassThru
"RESULT sampler pid=$($sampler.Id) wrapper=$wrapper file=$samples exe_via_env=$tele"
Start-Sleep -Seconds 12
$raw = @(); if (Test-Path -LiteralPath $samples) { $raw = @(Get-Content -LiteralPath $samples) }
$raw | Select-Object -First 3 | ForEach-Object { "RESULT sampler_raw $_" }
$n9070 = @($raw | Where-Object { $_ -match '^\S+ amd \d+ .*name "[^"]*9070[^"]*" watts [\d.]+ ' }).Count
"RESULT sampler_idle lines=$($raw.Count) lines_9070_with_watts=$n9070 sampler_alive=$(-not $sampler.HasExited)"
if ($n9070 -eq 0) {
$why = if ($raw.Count -eq 0) { 'the sampler wrote nothing in 12 s (file absent or empty)' } else { 'the sampler wrote ' + $raw.Count + ' line(s) but none is a 9070 XT line with a watts number (first lines above)' }
"RESULT watts error=$why; no rows are taken"
"RESULT APPROW class=v3 error=$why"
"RESULT LADDER pack=sh256x27 error=$why"
} else {
$samplerOk = $true
"RESULT idle $(Stamp) sampler proven on the idle window"
# ---- 1. the control row: the card ON in the app, mining class v3; /api/state at 1 Hz for 90 s, the sampler beside ----
$sc = @(StateCard)
if ($sc.Count -eq 0) { "RESULT APPROW class=v3 error=the app's /api/state lists no mining gfx1201 card (the card is off or the app is not running): the control row needs the card ON in the app" }
else {
"RESULT app_card key=$($sc[0].key) identities=$($sc[0].identities) state=$($sc[0].state) pid=$($sc[0].pid) mhs_now=$($sc[0].hash_now) watts=$($sc[0].power_w) (before the window)"
$appT0 = (Get-Date).ToUniversalTime()
"RESULT approw start $(Stamp) window_s=$appWindowS"
for ($i = 0; $i -lt $appWindowS; $i++) {
$c = @(StateCard)
if ($c.Count -gt 0) { $appRows += [pscustomobject]@{ ts = (Get-Date).ToUniversalTime(); w = [double] $c[0].power_w; mhs = [double] $c[0].hash_now; avg = [double] $c[0].hash_avg; state = "$($c[0].state)" } }
Start-Sleep -Seconds 1
}
$appT1 = (Get-Date).ToUniversalTime()
"RESULT approw end $(Stamp) polls=$($appRows.Count)"
}
# ---- 2. the candidate row: the card off in the app, the kit worker alone on sh256x27, the sampler as the only source ----
if ($entries.Count -gt 0) {
$before = @(CardWorkers)
"RESULT card_workers_before pids=[$($before -join ' ')]"
if ($before.Count -eq 0) { "RESULT card already quiet before the switch (no opencl worker with --device ${dev})"; $cardAlone = $true }
else {
PostCards $false; $switched = $true
$t = 0
while ($t -lt 150) { Start-Sleep -Seconds 5; $t += 5; if ((@(CardWorkers)).Count -eq 0) { break } }
$left = @(CardWorkers)
if ($left.Count -eq 0) { "RESULT card $(Stamp) quiet after $t s: no igneum-worker-opencl process with --device $dev (the card to itself)"; $cardAlone = $true }
else { "RESULT card $(Stamp) UNCONFIRMED after $t s: opencl worker pids [$($left -join ' ')] still carry --device $dev; the candidate row is a LOADED-card figure" }
Start-Sleep -Seconds 8
}
"RESULT workers_during $(Stamp) $((Workers) -join ' ')"
$instrs = 0
try { $pj = Get-Content -LiteralPath (Join-Path $packDir 'program.json') -Raw | ConvertFrom-Json; if ($pj.shadow) { $instrs = [int] $pj.shadow.instrs_per_hash } } catch { }
$t0 = (Get-Date).ToUniversalTime()
"RESULT bench sh256x27 start $(Stamp) batches=$candBatches shadow_instrs_per_hash=$instrs"
$lines = @(& $exe --bench-pack --pack $packDir --batches $candBatches --batch-log2 24 --device $dev 2>&1 | ForEach-Object { "$_" })
$rc = $LASTEXITCODE
$t1 = (Get-Date).ToUniversalTime()
$lines | ForEach-Object { "RESULT bench sh256x27 $_" }
"RESULT bench sh256x27 end $(Stamp) exit=$rc wall_s=$([int]($t1 - $t0).TotalSeconds)"
$res = $lines | Where-Object { $_ -match '^RESULT pack=' } | Select-Object -First 1
$fp = ''; $mhs = ''; $check = ''
if ($res -and $res -match 'fingerprint=([0-9a-f]{16})') { $fp = $Matches[1] }
if ($res -and $res -match 'mhs=([\d.]+)') { $mhs = $Matches[1] }
if ($res -and $res -match 'check=(\S+)') { $check = $Matches[1] }
if (-not $res -or $rc -ne 0) {
$err = ($lines | Where-Object { $_ -match 'FAIL|error|MISMATCH' } | Select-Object -First 1); if (-not $err) { $err = "exit $rc, no RESULT line" }
"RESULT G1 pack=sh256x27 error=$($err -replace '\s+', ' ')"; "RESULT LADDER pack=sh256x27 error=$($err -replace '\s+', ' ')"
} else {
"RESULT G1 pack=sh256x27 fingerprint=$fp match=$(if ($fp -eq $expectedFp) { 'yes' } else { 'no' }) expected=$expectedFp check=$check card=gfx1201 harness=opencl-kit"
$cand = [pscustomobject]@{ instrs = $instrs; mhs = $mhs; fp = $fp; t0 = $t0; t1 = $t1 }
}
} else { "RESULT LADDER pack=sh256x27 error=no settings.json entry for the card: it cannot be switched off for the card-alone row" }
}
} finally {
if ($sampler) { try { & taskkill /T /F /PID $sampler.Id 2>&1 | Out-Null; "RESULT sampler ended pid=$($sampler.Id)" } catch { "RESULT sampler end error=$($_.Exception.Message)" } }
if ($switched) {
PostCards $true
"RESULT card restore posted: each settings.json entry with its own enabled flag and identities"
$t = 0; $back = @()
$anyEnabled = ($entries | Where-Object { $_.enabled }).Count -gt 0
while ($t -lt 90 -and $anyEnabled) { Start-Sleep -Seconds 5; $t += 5; $back = @(CardWorkers); if ($back.Count -gt 0) { break } }
"RESULT card_workers_after $(Stamp) pids=[$($back -join ' ')] after $t s $(if ($back.Count -gt 0) { '(the card mines again)' } elseif ($anyEnabled) { '(NOT back yet: check the app)' } else { '(no entry was enabled before this job)' })"
}
"RESULT workers_after $(Stamp) $((Workers) -join ' ')"
}
# ---- the helper samples (9070 XT lines) ----
$rows = @()
if (Test-Path -LiteralPath $samples) {
foreach ($l in (Get-Content -LiteralPath $samples)) {
if ($l -match '^(\S+) amd (\d+) bus (\S+) kind (\S+) name "([^"]*)" watts (\S+) temp_c (\S+) fan_rpm (\S+) fan_pct (\S+) mclk_mhz (\S+) gclk_mhz (\S+) util_pct (\S+) source (\S+)') {
if ($Matches[5] -notmatch '9070') { continue }
try { $ts = [datetime]::ParseExact($Matches[1], 'yyyy-MM-ddTHH:mm:ss.fff', [Globalization.CultureInfo]::InvariantCulture) } catch { continue }
$w = if ($Matches[6] -eq '-') { $null } else { [double] $Matches[6] }
$rows += [pscustomobject]@{ ts = $ts; w = $w; temp = $Matches[7]; fan = $Matches[8]; mclk = $Matches[10]; gclk = $Matches[11]; util = $Matches[12]; src = $Matches[13] }
}
}
}
"RESULT samples total $($rows.Count) file $samples (9070 XT lines)"
function Window([datetime] $t0, [datetime] $t1, [int] $head, [int] $tail) { @($rows | Where-Object { $_.ts -gt $t0.AddSeconds($head) -and $_.ts -lt $t1.AddSeconds(-$tail) -and $null -ne $_.w }) }
# the control row from the app, and the sampler against it over the same window
$ratio = $null; $appMean = $null; $helperMean = $null
if ($appRows.Count -gt 0) {
$valid = @($appRows | Where-Object { $_.w -gt 0 })
$m = $valid | Measure-Object -Property w -Average -Minimum -Maximum
$mh = ($valid | Measure-Object -Property mhs -Average).Average
$ma = ($valid | Measure-Object -Property avg -Average).Average
$distinct = ($valid | Select-Object -ExpandProperty w -Unique | Measure-Object).Count
$appMean = $m.Average
$uj = if ($mh -gt 0) { [math]::Round($m.Average / $mh, 3) } else { 'owed' }
"RESULT APPROW class=v3 mhs_wall=$([math]::Round($mh, 3)) mhs_avg_app=$([math]::Round($ma, 3)) watts_mean=$([math]::Round($m.Average, 1)) watts_min=$($m.Minimum) watts_max=$($m.Maximum) samples=$($valid.Count) polls=$($appRows.Count) distinct_watts=$distinct uj=$uj window_s=$appWindowS source=api/state (the app's own helper at -l 5: the watts value moves every 5 s) states=$(($appRows | Select-Object -ExpandProperty state -Unique) -join ',')"
$hin = @(Window $appT0 $appT1 0 0)
if ($hin.Count -gt 0 -and $appMean -gt 0) {
$helperMean = ($hin | Measure-Object -Property w -Average).Average
$ratio = [math]::Round($helperMean / $appMean, 4)
"RESULT sampler_vs_app helper_mean=$([math]::Round($helperMean, 1)) app_mean=$([math]::Round($appMean, 1)) ratio=$ratio helper_samples=$($hin.Count) app_samples=$($valid.Count) window_s=$appWindowS calibration=$(if ($ratio -ge 0.95 -and $ratio -le 1.05) { 'inside 0.95..1.05' } else { 'OUTSIDE 0.95..1.05' })"
} else { "RESULT sampler_vs_app error=no helper samples inside the app window (helper $($hin.Count), app mean $appMean)" }
}
# the candidate row from the sampler, calibrated
if ($cand) {
$in = @(Window $cand.t0 $cand.t1 12 2)
$watts = 'owed'; $uj = 'owed'; $gclk = 'owed'; $mclk = 'owed'; $tmax = 'owed'; $wmin = ''; $wmax = ''; $why = ''
if ($in.Count -eq 0) { $why = 'no helper samples inside the bench window' }
elseif ($null -eq $ratio) { $why = 'no calibration ratio (the app window gave no pair of means)' }
elseif ($ratio -lt 0.95 -or $ratio -gt 1.05) { $why = "calibration ratio $ratio outside 0.95..1.05: the helper's reading beside the app's disagrees; raw helper mean $([math]::Round(($in | Measure-Object -Property w -Average).Average, 1)) W kept for the record only" }
else {
$m = $in | Measure-Object -Property w -Average -Minimum -Maximum
$watts = [math]::Round($m.Average, 1); $wmin = $m.Minimum; $wmax = $m.Maximum
$gclk = [math]::Round((($in | ForEach-Object { [double] $_.gclk }) | Measure-Object -Average).Average)
$mclk = [math]::Round((($in | ForEach-Object { [double] $_.mclk }) | Measure-Object -Average).Average)
$tmax = (($in | ForEach-Object { [double] $_.temp }) | Measure-Object -Maximum).Maximum
if ($cand.mhs -and [double] $cand.mhs -gt 0) { $uj = [math]::Round($m.Average / [double] $cand.mhs, 3) }
}
"RESULT LADDER pack=sh256x27 ops=102100 shadow_instrs=$($cand.instrs) mhs=$($cand.mhs) watts=$watts uj=$uj watts_min=$wmin watts_max=$wmax gclk_mhz=$gclk mclk_mhz=$mclk temp_max=$tmax samples=$($in.Count) window_s=$([int]($cand.t1 - $cand.t0).TotalSeconds) watts_source=adlx calibration_ratio=$ratio fingerprint=$($cand.fp) card_alone=$cardAlone$(if ($why) { " owed_reason=$why" })"
}
if ($rows.Count -gt 0) { foreach ($r in $rows) { "RESULT sample $($r.ts.ToString('HH:mm:ss')) $($r.w) $($r.temp) $($r.fan) $($r.mclk) $($r.gclk) $($r.util) $($r.src)" } }
$haveApp = $appRows.Count -gt 0
$haveCand = $null -ne $cand -and $null -ne $ratio -and $ratio -ge 0.95 -and $ratio -le 1.05
$status = if (-not $samplerOk) { 'failed' } elseif ($haveApp -and $haveCand) { 'done' } elseif ($haveApp -or $cand) { 'partial' } else { 'failed' }
"RESULT end $(Stamp)"
Summary $status @{ approw = $haveApp; candidate_row = [bool]$cand; calibration_ratio = "$ratio"; candidate_watts_taken = $haveCand; sampler_proven = $samplerOk; card_alone = $cardAlone; device = $dev }
if ($status -eq 'failed') { exit 1 }
exit 0