배터리 잔여 시간을 실제 방전 추세로 계산
This commit is contained in:
+277
-55
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user