Files
laptop-battery-testing/Test-BatteryDrain.ps1
T
2026-08-28 09:40:46 +03:00

354 lines
15 KiB
PowerShell

<#
.SYNOPSIS
Disables Windows power-saving features (including hibernate on low battery),
applies a synthetic CPU load, and logs battery drain over time.
.DESCRIPTION
1. Re-launches itself elevated if not running as Administrator (powercfg changes require admin).
2. Disables sleep/hibernate/monitor timeouts, USB selective suspend, PCI Express link power
management, and the "hibernate/shutdown on critical/low battery" actions, on both AC and DC.
3. Sets the active power plan to High performance (or Ultimate Performance if available).
4. Starts a background synthetic load across all logical processors targeting ~30% CPU usage
(configurable via -TargetLoadPercent).
5. Samples battery percentage/status every -SampleIntervalSeconds and appends to a CSV log
until -DurationMinutes elapses (or indefinitely with -DurationMinutes 0, stop with Ctrl+C).
6. Prints a summary (start/end charge, elapsed time, average drain rate %/hour).
.PARAMETER DurationMinutes
How long to run the test. 0 = run until Ctrl+C is pressed.
.PARAMETER SampleIntervalSeconds
How often to sample/log the battery status.
.PARAMETER TargetLoadPercent
Approximate overall CPU load to generate (0-100). Default 30.
.PARAMETER LogPath
Path to the CSV log file. Defaults to .\BatteryDrainLog_<timestamp>.csv next to the script.
.PARAMETER SkipPowerSettingsChanges
Skip modifying power settings; only run the load + logging.
.EXAMPLE
.\Test-BatteryDrain.ps1 -DurationMinutes 120 -TargetLoadPercent 30
.NOTES
Restoring defaults: the script does not automatically revert power settings it changes.
Run `powercfg /restoredefaultschemes` (drastic) or re-enable individual settings manually
if you want the previous behavior back.
#>
[CmdletBinding()]
param(
[int]$DurationMinutes = 60,
[int]$SampleIntervalSeconds = 30,
[ValidateRange(0, 100)]
[int]$TargetLoadPercent = 30,
[string]$LogPath = $(Join-Path -Path $PSScriptRoot -ChildPath ("BatteryDrainLog_{0:yyyyMMdd_HHmmss}.csv" -f (Get-Date))),
[string]$StateFilePath = $(Join-Path -Path $PSScriptRoot -ChildPath 'PowerSettingsBackup.json'),
[switch]$SkipPowerSettingsChanges
)
$ErrorActionPreference = 'Stop'
#region Elevation check
function Test-IsAdmin {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
if (-not (Test-IsAdmin)) {
Write-Host "Not running elevated - relaunching as Administrator..." -ForegroundColor Yellow
$argList = @(
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"",
'-DurationMinutes', $DurationMinutes,
'-SampleIntervalSeconds', $SampleIntervalSeconds,
'-TargetLoadPercent', $TargetLoadPercent,
'-LogPath', "`"$LogPath`"",
'-StateFilePath', "`"$StateFilePath`""
)
if ($SkipPowerSettingsChanges) { $argList += '-SkipPowerSettingsChanges' }
Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -Verb RunAs
exit
}
#endregion
#region Power settings
function Save-PowerSettingsState {
param([string]$Path)
# Original active scheme GUID (strip the trailing description in parentheses).
$activeLine = (powercfg /getactivescheme) -join ''
$activeGuid = if ($activeLine -match '([0-9a-fA-F-]{36})') { $Matches[1] } else { $null }
# Hibernate is considered enabled if hiberfil.sys exists / powercfg /a doesn't list it as unavailable due to being off.
$hibernateEnabled = (powercfg /a) -join "`n" -notmatch 'Hibernation has not been enabled'
$state = [PSCustomObject]@{
OriginalSchemeGuid = $activeGuid
HibernateEnabled = [bool]$hibernateEnabled
SavedAt = (Get-Date).ToString('o')
}
$state | ConvertTo-Json | Out-File -FilePath $Path -Encoding UTF8
Write-Host "Saved original power settings to $Path (used by Restore-PowerSettings.ps1)." -ForegroundColor DarkGray
}
function Set-PowerSettingsForBatteryTest {
Write-Host "Configuring power plan to prevent sleep/hibernate/screen-off and low-battery actions..." -ForegroundColor Cyan
Save-PowerSettingsState -Path $StateFilePath
# Try to use Ultimate/High performance plan so Windows itself doesn't throttle or sleep.
$schemes = powercfg /list
$ultimateGuid = ($schemes | Select-String -Pattern 'Ultimate Performance' | ForEach-Object {
($_ -match '([0-9a-fA-F-]{36})') | Out-Null; $Matches[1]
}) | Select-Object -First 1
if (-not $ultimateGuid) {
try {
$dup = powercfg -duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 2>$null
$ultimateGuid = ($dup | Select-String -Pattern '([0-9a-fA-F-]{36})' | ForEach-Object { $Matches[1] }) | Select-Object -First 1
} catch { }
}
if ($ultimateGuid) {
powercfg /setactive $ultimateGuid | Out-Null
Write-Host "Active plan set to Ultimate Performance." -ForegroundColor Green
} else {
# SCHEME_MIN = High performance
powercfg /setactive SCHEME_MIN | Out-Null
Write-Host "Active plan set to High performance." -ForegroundColor Green
}
# Disable timeouts for sleep, hibernate, monitor, disk - both AC (ac) and DC (battery, dc).
$targets = @('monitor-timeout-ac', 'monitor-timeout-dc',
'disk-timeout-ac', 'disk-timeout-dc',
'standby-timeout-ac', 'standby-timeout-dc',
'hibernate-timeout-ac', 'hibernate-timeout-dc')
foreach ($t in $targets) {
powercfg /change $t 0 | Out-Null
}
# Disable "Allow hybrid sleep" and standby/hibernate at the GUID level (belt & suspenders).
# SUB_SLEEP settings
$SUB_SLEEP = '238c9fa8-0aad-41ed-83f4-97be242c8f20'
$STANDBYIDLE = '29f6c1db-86da-48c5-9fdb-f2b67b1f44da'
$HIBERNATEIDLE = '9d7815a6-7ee4-497e-8888-515a05f02364'
foreach ($mode in @('/setacvalueindex', '/setdcvalueindex')) {
powercfg $mode SCHEME_CURRENT $SUB_SLEEP $STANDBYIDLE 0 | Out-Null
powercfg $mode SCHEME_CURRENT $SUB_SLEEP $HIBERNATEIDLE 0 | Out-Null
}
# Disable low/critical battery actions (hibernate/shutdown) - the actual ask: no hibernate on low battery.
$SUB_BATTERY = 'e73a048d-bf27-4f12-9731-8b2076e8891f'
$BATACTIONCRIT = '637ea02f-bbcb-4015-8e2c-a1c793b9d808' # Critical battery action
$BATACTIONLOW = 'd8742dcb-3e6a-4b3c-b3fe-374623cdcf06' # Low battery action
foreach ($mode in @('/setacvalueindex', '/setdcvalueindex')) {
# 0 = Do Nothing, 1 = Sleep, 2 = Hibernate, 3 = Shut Down
powercfg $mode SCHEME_CURRENT $SUB_BATTERY $BATACTIONCRIT 0 | Out-Null
powercfg $mode SCHEME_CURRENT $SUB_BATTERY $BATACTIONLOW 0 | Out-Null
}
# Turn off notifications tied to low battery flyout as well (0 = off).
$BATFLAGSCRIT = '5dbb7c9f-38e9-40d2-9749-4f8a0e9f640f'
$BATFLAGSLOW = 'bcded951-187b-4d05-bccc-f7e51960c258'
foreach ($mode in @('/setacvalueindex', '/setdcvalueindex')) {
powercfg $mode SCHEME_CURRENT $SUB_BATTERY $BATFLAGSCRIT 0 2>$null | Out-Null
powercfg $mode SCHEME_CURRENT $SUB_BATTERY $BATFLAGSLOW 0 2>$null | Out-Null
}
# Explicitly disable OS-level hibernate (removes hiberfil.sys, guarantees no hibernate trigger).
powercfg /hibernate off | Out-Null
# Disable USB selective suspend and PCI Express link state power management (both drain-reducers, but they
# can throttle performance under sustained load, which we don't want while stress-testing).
$SUB_USB = '2a737441-1930-4402-8d77-b2bebba308a3'
$USBSUSPEND = '48e6b7a6-50f5-4782-a5d4-53bb8f07e226'
$SUB_PCIE = '501a4d13-42af-4429-9fd1-a8218c268e20'
$PCIE_ASPM = 'ee12f906-d277-404b-b6da-e5fa1a576df5'
foreach ($mode in @('/setacvalueindex', '/setdcvalueindex')) {
powercfg $mode SCHEME_CURRENT $SUB_USB $USBSUSPEND 0 2>$null | Out-Null
powercfg $mode SCHEME_CURRENT $SUB_PCIE $PCIE_ASPM 0 2>$null | Out-Null
}
# Disable adaptive brightness / dimming on battery.
$SUB_VIDEO = '7516b95f-f776-4464-8c53-06167f40cc99'
$ADAPTBRIGHT = 'fbd9aa66-9553-4097-ba44-ed6e9d65eab8'
$DIMDISPLAY = '17aaa29b-8b43-4b94-aafe-35f64daaf1ee'
foreach ($mode in @('/setacvalueindex', '/setdcvalueindex')) {
powercfg $mode SCHEME_CURRENT $SUB_VIDEO $ADAPTBRIGHT 0 2>$null | Out-Null
powercfg $mode SCHEME_CURRENT $SUB_VIDEO $DIMDISPLAY 0 2>$null | Out-Null
}
powercfg /setactive SCHEME_CURRENT | Out-Null
# Prevent Windows from turning off the display/sleeping via the SendMessage/SetThreadExecutionState API too,
# as a redundant safety net for the duration of this script (handled later via a background job).
Write-Host "Power settings applied." -ForegroundColor Green
}
#endregion
#region Keep system awake (SetThreadExecutionState) for the life of the script
Add-Type -Namespace Native -Name Power -MemberDefinition @'
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern uint SetThreadExecutionState(uint esFlags);
'@
$ES_CONTINUOUS = 0x80000000
$ES_SYSTEM_REQUIRED = 0x00000001
$ES_DISPLAY_REQUIRED = 0x00000002
function Set-StayAwake {
[Native.Power]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED -bor $ES_DISPLAY_REQUIRED) | Out-Null
}
function Clear-StayAwake {
[Native.Power]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null
}
#endregion
#region Synthetic CPU load (~TargetLoadPercent across all logical cores)
function Start-CpuLoad {
param([int]$Percent, [int]$Cores)
$jobs = @()
for ($i = 0; $i -lt $Cores; $i++) {
$jobs += Start-Job -ScriptBlock {
param($pct)
$busyMs = [double]$pct
$idleMs = 100.0 - $busyMs
$sw = [System.Diagnostics.Stopwatch]::new()
while ($true) {
$sw.Restart()
while ($sw.Elapsed.TotalMilliseconds -lt $busyMs) {
# Busy-spin to consume CPU
[Math]::Sqrt(12345.6789) | Out-Null
}
if ($idleMs -gt 0) {
Start-Sleep -Milliseconds $idleMs
}
}
} -ArgumentList $Percent
}
return $jobs
}
function Stop-CpuLoad {
param($Jobs)
if ($Jobs) {
$Jobs | Stop-Job -PassThru | Remove-Job -Force
}
}
#endregion
#region Battery sampling
function Get-BatteryInfo {
$battery = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $battery) {
return [PSCustomObject]@{
Timestamp = Get-Date
PercentRemaining = $null
Status = 'No battery detected'
Charging = $null
EstimatedRunTime = $null
}
}
$chargingStatus = switch ($battery.BatteryStatus) {
1 { 'Discharging' }
2 { 'AC Power (Charged)' }
3 { 'Fully Charged' }
4 { 'Low' }
5 { 'Critical' }
6 { 'Charging' }
7 { 'Charging High' }
8 { 'Charging Low' }
9 { 'Charging Critical' }
10 { 'Undefined' }
11 { 'Partially Charged' }
default { "Unknown ($($battery.BatteryStatus))" }
}
[PSCustomObject]@{
Timestamp = Get-Date
PercentRemaining = $battery.EstimatedChargeRemaining
Status = $chargingStatus
Charging = ($battery.BatteryStatus -in 2, 6, 7, 8, 9)
EstimatedRunTime = $battery.EstimatedRunTime
}
}
#endregion
#region Main
try {
if (-not $SkipPowerSettingsChanges) {
Set-PowerSettingsForBatteryTest
} else {
Write-Host "Skipping power settings changes (per -SkipPowerSettingsChanges)." -ForegroundColor Yellow
}
Set-StayAwake
$cores = [Environment]::ProcessorCount
Write-Host "Starting synthetic CPU load targeting ~$TargetLoadPercent% across $cores logical processors..." -ForegroundColor Cyan
$loadJobs = Start-CpuLoad -Percent $TargetLoadPercent -Cores $cores
if (-not (Test-Path (Split-Path -Path $LogPath -Parent))) {
New-Item -ItemType Directory -Path (Split-Path -Path $LogPath -Parent) -Force | Out-Null
}
"Timestamp,PercentRemaining,Status,Charging,EstimatedRunTimeMinutes" | Out-File -FilePath $LogPath -Encoding UTF8
Write-Host "Logging battery status every $SampleIntervalSeconds s to $LogPath" -ForegroundColor Cyan
if ($DurationMinutes -gt 0) {
Write-Host "Test will run for $DurationMinutes minute(s). Press Ctrl+C to stop early." -ForegroundColor Cyan
} else {
Write-Host "Test will run indefinitely. Press Ctrl+C to stop." -ForegroundColor Cyan
}
$startTime = Get-Date
$endTime = if ($DurationMinutes -gt 0) { $startTime.AddMinutes($DurationMinutes) } else { [DateTime]::MaxValue }
$firstSample = $null
$lastSample = $null
while ((Get-Date) -lt $endTime) {
$info = Get-BatteryInfo
if (-not $firstSample) { $firstSample = $info }
$lastSample = $info
$line = "{0:o},{1},{2},{3},{4}" -f $info.Timestamp, $info.PercentRemaining, $info.Status, $info.Charging, $info.EstimatedRunTime
Add-Content -Path $LogPath -Value $line
Write-Host ("[{0:HH:mm:ss}] Battery: {1}% Status: {2}" -f $info.Timestamp, $info.PercentRemaining, $info.Status)
Start-Sleep -Seconds $SampleIntervalSeconds
}
}
finally {
Write-Host "Stopping CPU load and restoring system idle behavior..." -ForegroundColor Cyan
Stop-CpuLoad -Jobs $loadJobs
Clear-StayAwake
if ($firstSample -and $lastSample -and $firstSample.PercentRemaining -ne $null -and $lastSample.PercentRemaining -ne $null) {
$elapsedHours = ($lastSample.Timestamp - $firstSample.Timestamp).TotalHours
$drop = $firstSample.PercentRemaining - $lastSample.PercentRemaining
$rate = if ($elapsedHours -gt 0) { $drop / $elapsedHours } else { 0 }
Write-Host ""
Write-Host "===== Battery Drain Test Summary =====" -ForegroundColor Yellow
Write-Host ("Start: {0:yyyy-MM-dd HH:mm:ss} {1}%" -f $firstSample.Timestamp, $firstSample.PercentRemaining)
Write-Host ("End: {0:yyyy-MM-dd HH:mm:ss} {1}%" -f $lastSample.Timestamp, $lastSample.PercentRemaining)
Write-Host ("Elapsed: {0:N2} hour(s)" -f $elapsedHours)
Write-Host ("Total drop: {0} percentage points" -f $drop)
Write-Host ("Average drain rate: {0:N2} %/hour" -f $rate)
Write-Host ("Log file: {0}" -f $LogPath)
Write-Host "=======================================" -ForegroundColor Yellow
}
if (-not $SkipPowerSettingsChanges) {
Write-Host "Run .\Restore-PowerSettings.ps1 to revert the power/hibernate settings changed by this script." -ForegroundColor Cyan
}
}
#endregion