배터리 잔여 시간을 실제 방전 추세로 계산
This commit is contained in:
@@ -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부터 다시 누적됩니다.
|
||||||
|
|||||||
+277
-55
@@ -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) {
|
||||||
if (BATTERY_CAPACITY_WH > 0 && $avgWatts !== null && $avgWatts > 0) {
|
$weight = (float)$candidate['weight'];
|
||||||
$remainingWh = BATTERY_CAPACITY_WH * ($percent / 100);
|
$weightedRate += (float)$candidate['rate_per_second'] * $weight;
|
||||||
$seconds = (int)round(($remainingWh / $avgWatts) * 3600);
|
$weightSum += $weight;
|
||||||
|
|
||||||
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) {
|
if ($weightedRate > 0 && $weightSum > 0) {
|
||||||
$first = $socRows[0];
|
$rate = $weightedRate / $weightSum;
|
||||||
$last = $socRows[count($socRows) - 1];
|
$seconds = (int)round($percent / $rate);
|
||||||
$elapsed = max(1, (int)$last['time'] - (int)$first['time']);
|
$avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0);
|
||||||
$drop = (float)$first['soc'] - (float)$last['soc'];
|
|
||||||
|
|
||||||
if ($elapsed >= 120 && $drop >= 0.2) {
|
|
||||||
$dropPerSecond = $drop / $elapsed;
|
|
||||||
$seconds = (int)round($percent / $dropPerSecond);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'display' => human_remaining_seconds($seconds),
|
'display' => human_remaining_seconds($seconds),
|
||||||
'seconds' => $seconds,
|
'seconds' => $seconds,
|
||||||
'source' => 'soc_trend',
|
'source' => 'soc_multi_window',
|
||||||
'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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user