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

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
+26 -5
View File
@@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- PWM slider 기반 수동 팬 제어 - PWM slider 기반 수동 팬 제어
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트 - CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인 - RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
- 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산 - 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 30분/1시간/3시간/6시간/12시간/24시간 창으로 비교해 계산하고, CPU 전력 변화로 현재 부하를 보정
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시 - 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
- WiFi client 목록과 2.4G/5G client 수 표시 - WiFi client 목록과 2.4G/5G client 수 표시
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정 - 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
@@ -68,6 +68,22 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정 - `/home/seo/secret/control.php`: 앱 비밀번호, DB 설정, 배터리 설정, HA 알림 설정
- 배터리 용량 설정은 `/home/seo/secret/control.php``battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다. - 배터리 용량 설정은 `/home/seo/secret/control.php``battery.cell_capacity_mah`, `battery.parallel_cells`, `battery.nominal_voltage`, `battery.capacity_wh`를 사용합니다.
## 배터리 잔여 시간 계산
잔여 시간은 단순히 현재 CPU 전력 평균만으로 계산하지 않습니다. 실제 배터리 SOC가 DB에서 어떤 속도로 떨어졌는지를 먼저 사용합니다.
1. `sensor_logs`의 최근 24시간 배터리 기록을 1분 단위로 SQL 집계합니다.
2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다.
3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다.
4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다.
5. 최근 CPU 전력이 해당 창의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다.
6. 여러 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수를 기준으로 계산합니다.
7. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다.
WebSocket 장기 실행 프로세스에서는 1분 단위 집계 결과를 55초 동안 재사용해 DB 부담을 줄입니다. HTTP 요청은 요청 단위로 계산하되, 집계 범위가 최근 24시간으로 제한되어 전체 DB를 훑지 않습니다.
대시보드의 잔여 시간 값에는 계산 근거 tooltip이 붙습니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다.
## HA 알림 구조 ## HA 알림 구조
Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다. Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
@@ -120,9 +136,10 @@ control-wifi-observe.service
8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다. 8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다. 9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다. 10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다. 11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 여러 시간창으로 나눠 평가하고, 현재 CPU 전력 변화로 보정합니다.
12. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`서 읽어 진단 접힘 영역에 표시합니다. 12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs` 결과를 저장합니다.
13. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다. 13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
## 주요 함수/모듈 ## 주요 함수/모듈
@@ -141,6 +158,9 @@ control-wifi-observe.service
- `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택 - `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송 - `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회 - `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회
- `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계
- `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세와 현재 부하 보정으로 잔여 시간 계산
- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI - `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신 - `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어 - `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
@@ -160,7 +180,8 @@ control-wifi-observe.service
## 운영 체크포인트 ## 운영 체크포인트
- 센서 수집 주기와 DB 증가량을 확인합니다. - 센서 수집 주기와 DB 증가량을 확인합니다.
- 배터리팩 변경 시 `/home/seo/secret/control.php``battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`니다. - 배터리팩 변경 시 `/home/seo/secret/control.php``battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다.
- 잔여 시간이 갑자기 흔들리면 tooltip의 `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다.
- 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다. - 하드웨어 또는 OS 변경 후 fan sysfs 경로를 확인합니다.
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다. - 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다. - 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
+278 -56
View File
@@ -685,7 +685,220 @@ function sensor_history(int $limit = 240): array
return array_reverse($stmt->fetchAll()); 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; $percent = $battery['percent'] ?? null;
if ($percent === null || $percent === '' || !is_numeric($percent)) { 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)); $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) { $candidates = [];
$watts = $row['cpu_watts'] ?? null; foreach ([1800, 3600, 10800, 21600, 43200, 86400] as $windowSeconds) {
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { $candidate = battery_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts);
$wattValues[] = (float)$watts; if ($candidate !== null) {
$candidates[] = $candidate;
} }
} }
$avgWatts = $wattValues === [] if ($candidates !== []) {
? null $weightedRate = 0.0;
: array_sum($wattValues) / count($wattValues); $weightSum = 0.0;
foreach ($candidates as $candidate) {
$weight = (float)$candidate['weight'];
$weightedRate += (float)$candidate['rate_per_second'] * $weight;
$weightSum += $weight;
}
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) { if ($weightedRate > 0 && $weightSum > 0) {
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100); $rate = $weightedRate / $weightSum;
$seconds = (int)round(($remainingWh / $avgWatts) * 3600); $seconds = (int)round($percent / $rate);
$avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0);
return [ return [
'display' => human_remaining_seconds($seconds), 'display' => human_remaining_seconds($seconds),
'seconds' => $seconds, 'seconds' => $seconds,
'source' => 'avg_watts', 'source' => 'soc_multi_window',
'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 (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);
return [
'display' => human_remaining_seconds($seconds),
'seconds' => $seconds,
'source' => 'soc_trend',
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3), '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 [ return battery_power_fallback_estimate($percent, $history);
'display' => '-',
'seconds' => null,
'source' => BATTERY_CAPACITY_WH > 0 ? 'avg_watts_missing' : 'battery_capacity_missing',
'avg_watts' => $avgWatts === null ? null : round($avgWatts, 3),
];
} }
function add_battery_remaining_history(array $history): array function add_battery_remaining_history(array $history): array
{ {
$wattWindow = []; $wattWindow = [];
$socWindow = [];
foreach ($history as $index => $row) { foreach ($history as $index => $row) {
$watts = $row['cpu_watts'] ?? null; $watts = $row['cpu_watts'] ?? null;
if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) { if ($watts !== null && $watts !== '' && is_numeric($watts) && (float)$watts > 0) {
$wattWindow[] = (float)$watts; $wattWindow[] = (float)$watts;
if (count($wattWindow) > 90) { if (count($wattWindow) > 180) {
array_shift($wattWindow); array_shift($wattWindow);
} }
} }
$percent = $row['battery_percent'] ?? null; $percent = $row['battery_percent'] ?? null;
$avgWatts = $wattWindow === [] $time = strtotime((string)($row['time'] ?? ''));
? null if ($percent !== null && $percent !== '' && is_numeric($percent) && $time > 0) {
: array_sum($wattWindow) / count($wattWindow); $socWindow[] = [
'time' => $time,
'soc' => (float)$percent,
];
if (count($socWindow) > 240) {
array_shift($socWindow);
}
}
$history[$index]['battery_remaining_seconds'] = null; $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)); $safePercent = max(0.0, min(100.0, (float)$percent));
$remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100); $remainingWh = BATTERY_CAPACITY_WH * ($safePercent / 100);
$history[$index]['battery_remaining_seconds'] = (int)round(($remainingWh / $avgWatts) * 3600); $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)); $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); $processes = process_resource_data(6);
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes); $fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
+35
View File
@@ -674,6 +674,38 @@
if (node) node.textContent = value ?? 'N/A'; 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) { function renderTop(data) {
setText(els.updated, data.generated_at || '-'); setText(els.updated, data.generated_at || '-');
setText(els.temp, Number(data.system?.temp_c || 0).toFixed(1) + '°C'); 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.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.statusBatterySoc, battery.percent === null || battery.percent === undefined ? '-' : `${Number(battery.percent).toFixed(2)}%`);
setText(els.statusBatteryRemaining, battery.remaining?.display || '-'); setText(els.statusBatteryRemaining, battery.remaining?.display || '-');
if (els.statusBatteryRemaining) {
els.statusBatteryRemaining.title = batteryRemainingTitle(battery.remaining);
}
if (els.statusUsers) { if (els.statusUsers) {
const names = String(activeUsers.names || '').trim(); const names = String(activeUsers.names || '').trim();
els.statusUsers.innerHTML = names els.statusUsers.innerHTML = names