Private/Kinds/Cpu.ps1

# The Cpu Kind: what the processor is doing when nobody is asking it to do anything.
#
# A machine that is busy at rest is busy for a reason - a scan, an update, an indexer, a
# runaway process - and the Section names the culprit so second level does not have to
# revisit the machine.

$script:CpuSampleCount = 5

# The process snapshot is a rate over an interval rather than an instant, so it needs its
# own settle regardless of the shared sampling wait.
$script:CpuProcessSampleSeconds = 2

function Get-CpuData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $filter = "Name='_Total'"
    # The first read of a formatted-performance class is primed with zeroes; discard it.
    [void](Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue)

    # One step per reading plus the process snapshot's settle, which is the longest wait.
    $steps = $script:CpuSampleCount + 1
    $samples = @(1..$script:CpuSampleCount | ForEach-Object {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:CpuSampleCount) -Step ($_ - 1) -Of $steps
        Wait-Sample
        Get-CimInstance Win32_PerfFormattedData_Counters_ProcessorInformation -Filter $filter -ErrorAction SilentlyContinue
    })

    # The script divided this counter by a core count the System Check had stashed in
    # script scope, so reordering the two Checks silently changed the column. Asking the
    # runtime directly removes that coupling. On Windows PowerShell 5.1 a machine with
    # more than 64 logical processors reports only the current processor group; no
    # Target Machine in the estate is close to that.
    $logical = [Environment]::ProcessorCount

    [void](Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue)
    $settled = Get-SampleSeconds -Seconds $script:CpuProcessSampleSeconds
    Write-CheckProgress -Status (Get-Text 'Console.Progress.Settle') -Step $script:CpuSampleCount -Of $steps
    Wait-Sample -Seconds $script:CpuProcessSampleSeconds

    $top = @(Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne '_Total' -and $_.Name -ne 'Idle' } |
        Sort-Object PercentProcessorTime -Descending | Select-Object -First 12 | ForEach-Object {
            [pscustomobject]@{
                Process    = $_.Name
                'CPU %'    = [math]::Round($_.PercentProcessorTime / [math]::Max($logical, 1), 1)
                'Private MB' = [math]::Round($_.WorkingSetPrivate / 1MB)
                'IO KB/s'  = [math]::Round($_.IODataBytesPersec / 1KB)
            }
        })

    [pscustomobject]@{
        PSTypeName              = 'Gutcheck.Data.Cpu'
        ProcessorTimeSamples    = @($samples | ForEach-Object { $_.PercentProcessorTime })
        PerformanceLimitSamples = @($samples | ForEach-Object { $_.PercentPerformanceLimit })
        TopProcessesByCpu       = $top
        # How long the process snapshot actually settled for, so the Section can say so
        # rather than claim a window it did not use.
        ProcessSampleSeconds    = $settled
        # What Windows itself says of a limit on the processor: the power plan's
        # maximum, and its thermal throttle. The firmware's own limit it does not say.
        LimitCause              = Get-CpuLimitCause
    }
}

function ConvertFrom-CpuPowerPlanMaximum {
    <#
    .SYNOPSIS
        The power plan's maximum processor state, for mains and for battery, out of what
        powercfg prints. Nothing where it printed no such setting. Pure.
    .DESCRIPTION
        powercfg prints in the language of Windows, so the lines are read by position:
        the setting's minimum, maximum and increment, then what is set for mains and for
        battery, each a number in hex. Read off this machine (German Windows 11 26300,
        2026-10-11): 0x0, 0x64, 0x1, 0x64, 0x64. The last two are taken.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Line)

    $numbers = @(@($Line) | ForEach-Object {
        $found = [regex]::Match("$_", '0x([0-9a-fA-F]{8})\s*$')
        if ($found.Success) { [Convert]::ToInt32($found.Groups[1].Value, 16) }
    })
    if ($numbers.Count -lt 5) { return }
    [pscustomobject]@{ Mains = $numbers[-2]; Battery = $numbers[-1] }
}

function Get-CpuLimitCause {
    <#
    .SYNOPSIS
        What Windows says of a limit on the processor: the active power plan's maximum
        processor state, and the thermal zones with their throttle. Never throws.
    .DESCRIPTION
        A Gatherer: it reads and decides nothing. Read is false for what could not be
        read, which is not "no limit".
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $plan = [pscustomobject]@{ Read = $false; Mains = $null; Battery = $null }
    try {
        $printed = @(& powercfg.exe /query SCHEME_CURRENT SUB_PROCESSOR PROCTHROTTLEMAX 2>$null)
        $max = ConvertFrom-CpuPowerPlanMaximum -Line $printed
        if ($max) { $plan = [pscustomobject]@{ Read = $true; Mains = $max.Mains; Battery = $max.Battery } }
    }
    catch { }

    $zones     = @()
    $zonesRead = $false
    try {
        $zones = @(Get-CimInstance Win32_PerfFormattedData_Counters_ThermalZoneInformation -ErrorAction Stop | ForEach-Object {
            # Temperature is in Kelvin.
            $kelvin = ConvertTo-Number $_.Temperature
            [pscustomobject]@{
                Name         = "$($_.Name)"
                PassiveLimit = ConvertTo-Number $_.PercentPassiveLimit
                TemperatureC = $(if ($null -ne $kelvin -and $kelvin -gt 0) { [int][math]::Round($kelvin - 273.15) })
            }
        })
        $zonesRead = $true
    }
    catch { }

    [pscustomobject]@{ PowerPlan = $plan; ThermalZones = $zones; ZonesRead = $zonesRead }
}

function Get-CpuJudgement {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    New-CpuLoadFinding             -Data $Data -Parameters $Parameters
    New-CpuPerformanceLimitFinding -Data $Data -Parameters $Parameters -Situation $Situation
}

function ConvertTo-CpuSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $seconds = ConvertTo-Number (Get-DataProperty $Data 'ProcessSampleSeconds')
    if ($null -eq $seconds) { $seconds = $script:CpuProcessSampleSeconds }

    New-Section -Title ((Get-Text 'Title.Cpu.TopProcessesByCpu') -f $seconds) -Help (Get-Text 'Help.Cpu.TopProcessesByCpu') -Row @(
        (Get-DataCollection $Data 'TopProcessesByCpu') | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Cpu.Process')]   = Get-DataProperty $_ 'Process'
            $row[(Get-Text 'Column.Cpu.Percent')]   = Get-DataProperty $_ 'CPU %'
            $row[(Get-Text 'Column.Cpu.PrivateMB')] = Get-DataProperty $_ 'Private MB'
            $row[(Get-Text 'Column.Cpu.IoKBps')]    = Get-DataProperty $_ 'IO KB/s'
            [pscustomobject]$row
        }
    )

    # What Windows says of a limit on the processor, where it was read.
    $cause = Get-DataProperty $Data 'LimitCause'
    $plan  = Get-DataProperty $cause 'PowerPlan'
    $zones = Get-DataCollection $cause 'ThermalZones'
    if ((Get-DataProperty $plan 'Read') -or $zones.Count) {
        $what  = Get-Text 'Column.Cpu.Limit.What'
        $value = Get-Text 'Column.Cpu.Limit.Value'
        New-Section -Title (Get-Text 'Title.Cpu.Limit') -Help (Get-Text 'Help.Cpu.Limit') -Row @(
            if (Get-DataProperty $plan 'Read') {
                $row = [ordered]@{}; $row[$what] = Get-Text 'Value.Cpu.Limit.Row.PlanMains'
                $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.Percent') -f (Get-DataProperty $plan 'Mains'); [pscustomobject]$row
                $row = [ordered]@{}; $row[$what] = Get-Text 'Value.Cpu.Limit.Row.PlanBattery'
                $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.Percent') -f (Get-DataProperty $plan 'Battery'); [pscustomobject]$row
            }
            $zones | ForEach-Object {
                $row = [ordered]@{}; $row[$what] = (Get-Text 'Value.Cpu.Limit.Row.Zone') -f (Get-DataProperty $_ 'Name')
                $row[$value] = (Get-Text 'Value.Cpu.Limit.Row.ZoneValue') -f (Get-DataProperty $_ 'PassiveLimit'), (Get-DataProperty $_ 'TemperatureC'); [pscustomobject]$row
            }
        )
    }
}

function New-CpuLoadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'CpuLoadAtRestWarnPercent' 30
    $fail = Get-Parameter $Parameters 'CpuLoadAtRestFailPercent' 60

    $average = Get-SampleAverage (Get-DataProperty $Data 'ProcessorTimeSamples')
    if ($null -eq $average) {
        return New-UnavailableFinding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') `
            -Hint (Get-Text 'Hint.Cpu.TheProcessorPerformanceCountersDid')
    }

    New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPULoadAtRest') -Severity (Get-Severity $average $warn $fail) `
        -Value ('{0:N0} %' -f $average) `
        -Hint (Get-Text 'Hint.Cpu.HighLoadWhileIdleSee')
}

function Get-CpuLimitExplanation {
    <#
    .SYNOPSIS
        Why the processor is limited, as far as Windows says: what to put beside the
        percentage, and what to do about it. Nothing where the cause was not gathered. Pure.
    .DESCRIPTION
        The power plan's maximum for the power the machine is on, and a thermal zone
        that throttles, are named. Where both were read and neither limits, it is the
        firmware. Where one of them was not read, nothing is blamed.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [AllowNull()]$Situation)

    $cause = Get-DataProperty $Data 'LimitCause'
    if ($null -eq $cause) { return }

    $plan   = Get-DataProperty $cause 'PowerPlan'
    $causes = @()
    $hints  = @()

    if (Get-DataProperty $plan 'Read') {
        # On mains unless the machine is known to be on battery.
        $source = Select-SituationText -Situation $Situation -Fact Power -Text @{ Battery = 'Battery'; Unknown = 'Mains' }
        $max    = ConvertTo-Number (Get-DataProperty $plan $source)
        if ($null -ne $max -and $max -lt 100) {
            $where = Get-Text 'Value.Cpu.Limit.OnMains'
            if ($source -eq 'Battery') { $where = Get-Text 'Value.Cpu.Limit.OnBattery' }
            $causes += (Get-Text 'Value.Cpu.Limit.Plan') -f $max, $where
            $hints  += Get-Text 'Hint.Cpu.Limit.Plan'
        }
    }

    $hot = @((Get-DataCollection $cause 'ThermalZones') | Where-Object {
        $limit = ConvertTo-Number (Get-DataProperty $_ 'PassiveLimit'); $null -ne $limit -and $limit -lt 100 } |
        Sort-Object { ConvertTo-Number (Get-DataProperty $_ 'PassiveLimit') })
    if ($hot.Count) {
        $causes += (Get-Text 'Value.Cpu.Limit.Thermal') -f (Get-DataProperty $hot[0] 'PassiveLimit'), (Get-DataProperty $hot[0] 'TemperatureC')
        $hints  += Get-Text 'Hint.Cpu.Limit.Thermal'
    }

    if (-not $causes.Count) {
        if ((Get-DataProperty $plan 'Read') -and (Get-DataProperty $cause 'ZonesRead')) {
            $causes += Get-Text 'Value.Cpu.Limit.Firmware'
            $hints  += Get-Text 'Hint.Cpu.Limit.Firmware'
        }
        else {
            $causes += Get-Text 'Value.Cpu.Limit.Unknown'
            $hints  += Get-Text 'Hint.Cpu.FirmwareOSIsCappingThe'
        }
    }
    [pscustomobject]@{ Cause = ($causes -join '; '); Hint = ($hints -join ' | ') }
}

function New-CpuPerformanceLimitFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters, [AllowNull()]$Situation)

    # Graded downwards, and the script only ever warned here, so the FAIL threshold
    # defaults to a percentage no reading can fall below.
    $warnBelow = Get-Parameter $Parameters 'CpuPerformanceLimitWarnBelowPercent' 90
    $failBelow = Get-Parameter $Parameters 'CpuPerformanceLimitFailBelowPercent' ([double]::MinValue)

    # Absent on processors that do not expose a performance limit. The script reported
    # nothing at all in that case rather than a gap, and so does this.
    $average = Get-SampleAverage (Get-DataProperty $Data 'PerformanceLimitSamples')
    if ($null -eq $average) { return }

    $severity = Get-SeverityBelow $average $warnBelow $failBelow
    $value    = '{0:N0} %' -f $average
    $hint     = Get-Text 'Hint.Cpu.FirmwareOSIsCappingThe'
    # What limits it, where it is limited and Windows says.
    if ($severity -in 'WARN', 'FAIL') {
        $why = Get-CpuLimitExplanation -Data $Data -Situation $Situation
        if ($why) {
            $value = (Get-Text 'Value.Cpu.Limit.Cause') -f ('{0:N0}' -f $average), $why.Cause
            $hint  = $why.Hint
        }
    }
    New-Finding -Category CPU -Check (Get-Text 'Check.Cpu.CPUPerformanceLimit') -Severity $severity `
        -Value $value -Meaning (Get-Text 'Meaning.Cpu.Limit') -Hint $hint
}

function ConvertTo-CpuFinding {
    <#
    .SYNOPSIS
        The Judge. What it measured, and - where that is not in order - that the machine
        was on battery when it was measured, which Windows makes slower.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    Get-CpuJudgement -Data $Data -Parameters $Parameters -Situation $Situation | Add-SituationPowerNote -Situation $Situation
}