배터리 잔여 시간을 실제 방전 추세로 계산

This commit is contained in:
seo
2026-06-26 02:03:16 +09:00
parent 527d7d6094
commit b6cdb0f162
3 changed files with 338 additions and 60 deletions
+277 -55
View File
@@ -685,7 +685,220 @@ function sensor_history(int $limit = 240): array
return array_reverse($stmt->fetchAll());
}
function battery_remaining_estimate(array $battery, array $history): array
function numeric_median(array $values): ?float
{
$numbers = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$numbers[] = (float)$value;
}
}
if ($numbers === []) {
return null;
}
sort($numbers, SORT_NUMERIC);
$count = count($numbers);
$mid = intdiv($count, 2);
return ($count % 2) === 1
? $numbers[$mid]
: ($numbers[$mid - 1] + $numbers[$mid]) / 2;
}
function numeric_trimmed_average(array $values, float $trimRatio = 0.1): ?float
{
$numbers = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$number = (float)$value;
if ($number > 0) {
$numbers[] = $number;
}
}
}
if ($numbers === []) {
return null;
}
sort($numbers, SORT_NUMERIC);
$trim = (int)floor(count($numbers) * max(0.0, min(0.4, $trimRatio)));
if ($trim > 0 && count($numbers) > ($trim * 2 + 2)) {
$numbers = array_slice($numbers, $trim, count($numbers) - ($trim * 2));
}
return array_sum($numbers) / count($numbers);
}
function clamp_float(float $value, float $min, float $max): float
{
return max($min, min($max, $value));
}
function battery_trend_history(int $hours = 24): array
{
$hours = max(1, min(48, $hours));
static $cache = [];
$cacheKey = (string)$hours;
$now = time();
if (
isset($cache[$cacheKey])
&& ($now - (int)$cache[$cacheKey]['time']) < 55
&& is_array($cache[$cacheKey]['rows'])
) {
return $cache[$cacheKey]['rows'];
}
$stmt = db()->query("
SELECT
FLOOR(UNIX_TIMESTAMP(recorded_at) / 60) AS bucket,
MIN(recorded_at) AS time,
AVG(battery_percent) AS battery_percent,
AVG(battery_voltage) AS battery_voltage,
AVG(cpu_watts) AS cpu_watts,
AVG(cpu_load_1) AS cpu_load_1,
COUNT(*) AS samples
FROM sensor_logs FORCE INDEX (idx_recorded_at)
WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$hours} HOUR)
AND battery_percent IS NOT NULL
GROUP BY bucket
ORDER BY bucket ASC
");
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$time = strtotime((string)($row['time'] ?? ''));
if ($time <= 0 || !is_numeric($row['battery_percent'] ?? null)) {
continue;
}
$rows[] = [
'time' => $time,
'battery_percent' => (float)$row['battery_percent'],
'battery_voltage' => is_numeric($row['battery_voltage'] ?? null) ? (float)$row['battery_voltage'] : null,
'cpu_watts' => is_numeric($row['cpu_watts'] ?? null) ? (float)$row['cpu_watts'] : null,
'cpu_load_1' => is_numeric($row['cpu_load_1'] ?? null) ? (float)$row['cpu_load_1'] : null,
'samples' => (int)($row['samples'] ?? 0),
];
}
$cache[$cacheKey] = [
'time' => $now,
'rows' => $rows,
];
return $rows;
}
function battery_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
{
if (count($trendRows) < 6) {
return null;
}
$lastTime = (int)$trendRows[count($trendRows) - 1]['time'];
$rows = array_values(array_filter($trendRows, static function (array $row) use ($lastTime, $windowSeconds): bool {
return (int)$row['time'] >= ($lastTime - $windowSeconds)
&& is_numeric($row['battery_percent'] ?? null);
}));
$count = count($rows);
if ($count < 6) {
return null;
}
$first = $rows[0];
$last = $rows[$count - 1];
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
if ($elapsed < 600) {
return null;
}
$edgeCount = max(3, min(10, (int)floor($count * 0.12)));
$startSoc = numeric_median(array_column(array_slice($rows, 0, $edgeCount), 'battery_percent'));
$endSoc = numeric_median(array_column(array_slice($rows, -$edgeCount), 'battery_percent'));
if ($startSoc === null || $endSoc === null) {
return null;
}
$drop = $startSoc - $endSoc;
$minDrop = $windowSeconds <= 3600 ? 0.12 : 0.22;
if ($drop < $minDrop) {
return null;
}
$rate = $drop / $elapsed;
if ($rate <= 0) {
return null;
}
$windowWatts = numeric_trimmed_average(array_column($rows, 'cpu_watts'), 0.1);
$loadFactor = 1.0;
if ($recentWatts !== null && $recentWatts > 0 && $windowWatts !== null && $windowWatts > 0) {
$loadFactor = 1 + ((clamp_float($recentWatts / $windowWatts, 0.65, 1.75) - 1) * 0.45);
$rate *= $loadFactor;
}
$seconds = (int)round($currentPercent / $rate);
if ($seconds <= 0) {
return null;
}
$confidence = min(1.0, $elapsed / $windowSeconds)
* min(1.0, $drop / 1.2)
* min(1.0, $count / 90);
return [
'seconds' => $seconds,
'rate_per_second' => $rate,
'drop_percent' => round($drop, 3),
'elapsed_seconds' => $elapsed,
'window_seconds' => $windowSeconds,
'sample_count' => $count,
'confidence' => round($confidence, 4),
'weight' => max(0.05, $confidence),
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
'load_factor' => round($loadFactor, 3),
];
}
function battery_power_fallback_estimate(float $percent, array $history): array
{
$wattValues = [];
foreach (array_slice($history, -180) as $row) {
$watts = $row['cpu_watts'] ?? null;
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
$wattValues[] = (float)$watts;
}
}
$avgWatts = numeric_trimmed_average($wattValues, 0.12);
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
return [
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'capacity_power_fallback',
'avg_watts' => round($avgWatts, 3),
'capacity_wh' => BATTERY_CAPACITY_WH,
];
}
return [
'display' => '-',
'seconds' => null,
'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing',
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
];
}
function battery_remaining_estimate(array $battery, array $history, array $trendRows = []): array
{
$percent = $battery['percent'] ?? null;
if ($percent === null || $percent === '' || !is_numeric($percent)) {
@@ -698,91 +911,99 @@ function battery_remaining_estimate(array $battery, array $history): array
}
$percent = max(0.0, min(100.0, (float)$percent));
$wattValues = [];
$recentWatts = numeric_trimmed_average(array_column(array_slice($trendRows, -10), 'cpu_watts'), 0.1)
?? numeric_trimmed_average(array_column(array_slice($history, -90), 'cpu_watts'), 0.1);
foreach (array_slice($history, -90) as $row) {
$watts = $row['cpu_watts'] ?? null;
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
$wattValues[] = (float)$watts;
$candidates = [];
foreach ([1800, 3600, 10800, 21600, 43200, 86400] as $windowSeconds) {
$candidate = battery_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts);
if ($candidate !== null) {
$candidates[] = $candidate;
}
}
$avgWatts = $wattValues === []
? null
: array_sum($wattValues) / count($wattValues);
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
return [
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'avg_watts',
'avg_watts' => round($avgWatts, 3),
'capacity_wh' => BATTERY_CAPACITY_WH,
];
}
$socRows = [];
foreach ($history as $row) {
$soc = $row['battery_percent'] ?? null;
$time = strtotime((string)($row['time'] ?? ''));
if ($soc !== null && $soc !== '' && is_numeric($soc) && $time > 0) {
$socRows[] = [
'time' => $time,
'soc' => (float)$soc,
];
if ($candidates !== []) {
$weightedRate = 0.0;
$weightSum = 0.0;
foreach ($candidates as $candidate) {
$weight = (float)$candidate['weight'];
$weightedRate += (float)$candidate['rate_per_second'] * $weight;
$weightSum += $weight;
}
}
if (count($socRows) >= 30) {
$first = $socRows[0];
$last = $socRows[count($socRows) - 1];
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
$drop = (float)$first['soc'] - (float)$last['soc'];
if ($elapsed >= 120 && $drop >= 0.2) {
$dropPerSecond = $drop / $elapsed;
$seconds = (int)round($percent / $dropPerSecond);
if ($weightedRate > 0 && $weightSum > 0) {
$rate = $weightedRate / $weightSum;
$seconds = (int)round($percent / $rate);
$avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0);
return [
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'soc_trend',
'source' => 'soc_multi_window',
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
'recent_watts' => $recentWatts === null ? null : round($recentWatts, 3),
'drop_per_hour' => round($rate * 3600, 3),
'confidence' => round(array_sum(array_column($candidates, 'confidence')) / count($candidates), 3),
'windows' => array_map(static function (array $candidate): array {
return [
'minutes' => (int)round($candidate['window_seconds'] / 60),
'drop_percent' => $candidate['drop_percent'],
'confidence' => $candidate['confidence'],
'load_factor' => $candidate['load_factor'],
];
}, $candidates),
];
}
}
return [
'display' => '-',
'seconds' => null,
'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing',
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
];
return battery_power_fallback_estimate($percent, $history);
}
function add_battery_remaining_history(array $history): array
{
$wattWindow = [];
$socWindow = [];
foreach ($history as $index => $row) {
$watts = $row['cpu_watts'] ?? null;
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
$wattWindow[] = (float)$watts;
if (count($wattWindow) > 90) {
if (count($wattWindow) > 180) {
array_shift($wattWindow);
}
}
$percent = $row['battery_percent'] ?? null;
$avgWatts = $wattWindow === []
? null
: array_sum($wattWindow) / count($wattWindow);
$time = strtotime((string)($row['time'] ?? ''));
if ($percent !== null && $percent !== '' && is_numeric($percent) && $time > 0) {
$socWindow[] = [
'time' => $time,
'soc' => (float)$percent,
];
if (count($socWindow) > 240) {
array_shift($socWindow);
}
}
$history[$index]['battery_remaining_seconds'] = null;
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0 && is_numeric($percent)) {
if (!is_numeric($percent)) {
continue;
}
$safePercent = max(0.0, min(100.0, (float)$percent));
if (count($socWindow) >= 30) {
$first = $socWindow[0];
$last = $socWindow[count($socWindow) - 1];
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
$drop = (float)$first['soc'] - (float)$last['soc'];
if ($elapsed >= 120 && $drop >= 0.12) {
$history[$index]['battery_remaining_seconds'] = (int)round($safePercent / ($drop / $elapsed));
continue;
}
}
$avgWatts = numeric_trimmed_average($wattWindow, 0.12);
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
$safePercent = max(0.0, min(100.0, (float)$percent));
$remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100);
$history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600);
@@ -2124,7 +2345,8 @@ function collect_snapshot(bool $applyFan = true): array
]);
$history = add_battery_remaining_history(sensor_history(240));
$battery['remaining'] = battery_remaining_estimate($battery, $history);
$batteryTrend = battery_trend_history(24);
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend);
$processes = process_resource_data(6);
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
+35
View File
@@ -674,6 +674,38 @@
if (node) node.textContent = value ?? 'N/A';
}
function batteryRemainingTitle(remaining) {
if (!remaining || typeof remaining !== 'object') {
return '';
}
const sourceMap = {
soc_multi_window: 'SOC 다중 시간창 추세',
capacity_power_fallback: '배터리 용량과 최근 전력 평균',
battery_soc_missing: 'SOC 없음',
avg_watts_missing: '전력 평균 없음',
battery_capacity_missing: '배터리 용량 설정 없음',
};
const lines = [sourceMap[remaining.source] || remaining.source || '-'];
if (remaining.drop_per_hour !== undefined && remaining.drop_per_hour !== null) {
lines.push(`시간당 SOC 감소: ${Number(remaining.drop_per_hour).toFixed(3)}%`);
}
if (remaining.confidence !== undefined && remaining.confidence !== null) {
lines.push(`추세 신뢰도: ${(Number(remaining.confidence) * 100).toFixed(0)}%`);
}
if (remaining.recent_watts !== undefined && remaining.recent_watts !== null) {
lines.push(`최근 CPU 전력: ${Number(remaining.recent_watts).toFixed(3)}W`);
} else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) {
lines.push(`평균 CPU 전력: ${Number(remaining.avg_watts).toFixed(3)}W`);
}
if (Array.isArray(remaining.windows) && remaining.windows.length > 0) {
lines.push(`반영 구간: ${remaining.windows.map(row => `${row.minutes}`).join(', ')}`);
}
return lines.join('\n');
}
function renderTop(data) {
setText(els.updated, data.generated_at || '-');
setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C');
@@ -867,6 +899,9 @@
setText(els.statusBatteryVoltage, battery.voltage === null || battery.voltage === undefined ? '-' : `${Number(battery.voltage).toFixed(3)} V`);
setText(els.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`);
setText(els.statusBatteryRemaining, battery.remaining?.display || '-');
if (els.statusBatteryRemaining) {
els.statusBatteryRemaining.title = batteryRemainingTitle(battery.remaining);
}
if (els.statusUsers) {
const names = String(activeUsers.names || '').trim();
els.statusUsers.innerHTML = names