배터리 잔여 시간을 실제 방전 추세로 계산
This commit is contained in:
@@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
||||
- PWM slider 기반 수동 팬 제어
|
||||
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
||||
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
||||
- 배터리 잔여 시간은 1S8P 병렬팩 `2200mAh x 8 = 17600mAh`, nominal `3.7V`, 총 `65.12Wh` 기준으로 계산
|
||||
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 30분/1시간/3시간/6시간/12시간/24시간 창으로 비교해 계산하고, CPU 전력 변화로 현재 부하를 보정
|
||||
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
|
||||
- WiFi client 목록과 2.4G/5G client 수 표시
|
||||
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
|
||||
@@ -68,6 +68,22 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
||||
- `/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`를 사용합니다.
|
||||
|
||||
## 배터리 잔여 시간 계산
|
||||
|
||||
잔여 시간은 단순히 현재 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 알림 구조
|
||||
|
||||
Control은 서버에서 Home Assistant webhook으로 알림 payload를 보내고, HA 자동화가 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Android 알림을 전달합니다.
|
||||
@@ -120,9 +136,10 @@ control-wifi-observe.service
|
||||
8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
|
||||
9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
|
||||
10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
|
||||
11. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
||||
12. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
||||
13. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
||||
11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 여러 시간창으로 나눠 평가하고, 현재 CPU 전력 변화로 보정합니다.
|
||||
12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
||||
13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
||||
14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
||||
|
||||
## 주요 함수/모듈
|
||||
|
||||
@@ -141,6 +158,9 @@ control-wifi-observe.service
|
||||
- `battery_low_notify_profile()`: 배터리 SOC 단계별 채널, 중요도, 아이콘, 진동 정책 선택
|
||||
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
|
||||
- `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/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
|
||||
- `assets/wakelock.js`: WakeLock 버튼 상태와 Screen Wake Lock API 제어
|
||||
@@ -160,7 +180,8 @@ control-wifi-observe.service
|
||||
## 운영 체크포인트
|
||||
|
||||
- 센서 수집 주기와 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 경로를 확인합니다.
|
||||
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
|
||||
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
|
||||
|
||||
+278
-56
@@ -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 ($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 (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
|
||||
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
|
||||
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
|
||||
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' => '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 (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',
|
||||
'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