잔여 시간 계산에 장기 학습 프로파일 반영
This commit is contained in:
@@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
- PWM slider 기반 수동 팬 제어
|
- PWM slider 기반 수동 팬 제어
|
||||||
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
|
||||||
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
|
||||||
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 30분/1시간/3시간/6시간/12시간/24시간 창으로 비교해 계산하고, CPU 전력 변화로 현재 부하를 보정
|
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
|
||||||
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
|
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
|
||||||
- WiFi client 목록과 2.4G/5G client 수 표시
|
- WiFi client 목록과 2.4G/5G client 수 표시
|
||||||
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
|
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
|
||||||
@@ -76,13 +76,15 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
|
|||||||
2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다.
|
2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다.
|
||||||
3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다.
|
3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다.
|
||||||
4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다.
|
4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다.
|
||||||
5. 최근 CPU 전력이 해당 창의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다.
|
5. 최대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일을 만듭니다.
|
||||||
6. 여러 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수를 기준으로 계산합니다.
|
6. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다.
|
||||||
7. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다.
|
7. 최근 CPU 전력이 각 후보 구간의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다.
|
||||||
|
8. 단기 후보와 장기 학습 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수, SOC 구간 다양성을 기준으로 계산합니다.
|
||||||
|
9. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다.
|
||||||
|
|
||||||
WebSocket 장기 실행 프로세스에서는 1분 단위 집계 결과를 55초 동안 재사용해 DB 부담을 줄입니다. HTTP 요청은 요청 단위로 계산하되, 집계 범위가 최근 24시간으로 제한되어 전체 DB를 훑지 않습니다.
|
WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다.
|
||||||
|
|
||||||
대시보드의 잔여 시간 값에는 계산 근거 tooltip이 붙습니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다.
|
대시보드의 잔여 시간 값에는 계산 근거 tooltip이 붙습니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다.
|
||||||
|
|
||||||
## HA 알림 구조
|
## HA 알림 구조
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ 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. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 여러 시간창으로 나눠 평가하고, 현재 CPU 전력 변화로 보정합니다.
|
11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다.
|
||||||
12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
|
||||||
13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
|
||||||
14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
|
||||||
@@ -159,7 +161,9 @@ control-wifi-observe.service
|
|||||||
- `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_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계
|
||||||
- `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세와 현재 부하 보정으로 잔여 시간 계산
|
- `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관
|
||||||
|
- `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산
|
||||||
|
- `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산
|
||||||
- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산
|
- `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 관측 세션을 선제 갱신
|
||||||
@@ -181,7 +185,7 @@ control-wifi-observe.service
|
|||||||
|
|
||||||
- 센서 수집 주기와 DB 증가량을 확인합니다.
|
- 센서 수집 주기와 DB 증가량을 확인합니다.
|
||||||
- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다.
|
- 배터리팩 변경 시 `/home/seo/secret/control.php`의 `battery` 값을 먼저 갱신합니다. 현재 기준은 `2200mAh x 8 parallel = 17600mAh`, nominal `65.12Wh`이며, SOC 추세가 부족한 경우의 fallback 계산에 사용됩니다.
|
||||||
- 잔여 시간이 갑자기 흔들리면 tooltip의 `반영 구간`, `시간당 SOC 감소`, `추세 신뢰도`, `최근 CPU 전력`을 먼저 확인합니다.
|
- 잔여 시간이 갑자기 흔들리면 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부터 다시 누적됩니다.
|
||||||
|
|||||||
+202
-3
@@ -793,6 +793,91 @@ function battery_trend_history(int $hours = 24): array
|
|||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function battery_profile_history(int $days = 45): array
|
||||||
|
{
|
||||||
|
$days = max(3, min(90, $days));
|
||||||
|
static $cache = [];
|
||||||
|
$cacheKey = (string)$days;
|
||||||
|
$now = time();
|
||||||
|
$cacheFile = sys_get_temp_dir() . '/control-battery-profile-' . $days . 'd.json';
|
||||||
|
|
||||||
|
if (
|
||||||
|
isset($cache[$cacheKey])
|
||||||
|
&& ($now - (int)$cache[$cacheKey]['time']) < 600
|
||||||
|
&& is_array($cache[$cacheKey]['rows'])
|
||||||
|
) {
|
||||||
|
return $cache[$cacheKey]['rows'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_readable($cacheFile)) {
|
||||||
|
$raw = @file_get_contents($cacheFile);
|
||||||
|
$decoded = is_string($raw) ? json_decode($raw, true) : null;
|
||||||
|
if (
|
||||||
|
is_array($decoded)
|
||||||
|
&& isset($decoded['created_at'], $decoded['rows'])
|
||||||
|
&& is_array($decoded['rows'])
|
||||||
|
&& ($now - (int)$decoded['created_at']) < 21600
|
||||||
|
) {
|
||||||
|
$cache[$cacheKey] = [
|
||||||
|
'time' => $now,
|
||||||
|
'rows' => $decoded['rows'],
|
||||||
|
];
|
||||||
|
return $decoded['rows'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxId = (int)db()->query("SELECT MAX(id) FROM sensor_logs")->fetchColumn();
|
||||||
|
$minId = max(1, $maxId - 3000000);
|
||||||
|
|
||||||
|
$stmt = db()->query("
|
||||||
|
SELECT
|
||||||
|
recorded_at AS time,
|
||||||
|
battery_percent,
|
||||||
|
battery_voltage,
|
||||||
|
cpu_watts,
|
||||||
|
cpu_load_1,
|
||||||
|
1 AS samples
|
||||||
|
FROM sensor_logs FORCE INDEX (idx_recorded_at)
|
||||||
|
WHERE recorded_at >= DATE_SUB(NOW(), INTERVAL {$days} DAY)
|
||||||
|
AND battery_percent IS NOT NULL
|
||||||
|
AND id >= {$minId}
|
||||||
|
AND MOD(id, 300) = 0
|
||||||
|
ORDER BY recorded_at 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,
|
||||||
|
];
|
||||||
|
@file_put_contents(
|
||||||
|
$cacheFile,
|
||||||
|
json_encode([
|
||||||
|
'created_at' => $now,
|
||||||
|
'days' => $days,
|
||||||
|
'rows' => $rows,
|
||||||
|
], JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
function battery_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
|
function battery_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
|
||||||
{
|
{
|
||||||
if (count($trendRows) < 6) {
|
if (count($trendRows) < 6) {
|
||||||
@@ -862,6 +947,110 @@ function battery_trend_candidate(array $trendRows, float $currentPercent, int $w
|
|||||||
'weight' => max(0.05, $confidence),
|
'weight' => max(0.05, $confidence),
|
||||||
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
|
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
|
||||||
'load_factor' => round($loadFactor, 3),
|
'load_factor' => round($loadFactor, 3),
|
||||||
|
'source' => 'recent_window',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function battery_learned_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
|
||||||
|
{
|
||||||
|
if (count($profileRows) < 24) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$rates = [];
|
||||||
|
$dropSum = 0.0;
|
||||||
|
$sampleSum = 0;
|
||||||
|
$socBuckets = [];
|
||||||
|
$minSoc = max(0.0, $currentPercent - 70.0);
|
||||||
|
$maxSoc = min(100.0, $currentPercent + 8.0);
|
||||||
|
|
||||||
|
for ($i = 1, $count = count($profileRows); $i < $count; $i++) {
|
||||||
|
$prev = $profileRows[$i - 1];
|
||||||
|
$row = $profileRows[$i];
|
||||||
|
$elapsed = (int)$row['time'] - (int)$prev['time'];
|
||||||
|
if ($elapsed < 240 || $elapsed > 900) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startSoc = (float)$prev['battery_percent'];
|
||||||
|
$endSoc = (float)$row['battery_percent'];
|
||||||
|
$avgSoc = ($startSoc + $endSoc) / 2;
|
||||||
|
if ($avgSoc < $minSoc || $avgSoc > $maxSoc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$drop = $startSoc - $endSoc;
|
||||||
|
if ($drop < 0.03 || $drop > 8.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$avgWatts = numeric_trimmed_average([$prev['cpu_watts'] ?? null, $row['cpu_watts'] ?? null], 0.0);
|
||||||
|
$loadFactor = 1.0;
|
||||||
|
if ($recentWatts !== null && $recentWatts > 0 && $avgWatts !== null && $avgWatts > 0) {
|
||||||
|
$loadFactor = 1 + ((clamp_float($recentWatts / $avgWatts, 0.55, 1.95) - 1) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
$socDistance = abs($currentPercent - $avgSoc);
|
||||||
|
$socWeight = 1 / (1 + ($socDistance / 18));
|
||||||
|
$ageDays = max(0.0, ($now - (int)$row['time']) / 86400);
|
||||||
|
$recencyWeight = 1 / (1 + ($ageDays / 45));
|
||||||
|
$sampleWeight = min(1.0, max(1, ((int)$prev['samples'] + (int)$row['samples']) / 2) / 30);
|
||||||
|
$weight = max(0.01, $socWeight * $recencyWeight * $sampleWeight);
|
||||||
|
|
||||||
|
$rates[] = [
|
||||||
|
'rate' => ($drop / $elapsed) * $loadFactor,
|
||||||
|
'weight' => $weight,
|
||||||
|
'soc' => $avgSoc,
|
||||||
|
'drop' => $drop,
|
||||||
|
'watts' => $avgWatts,
|
||||||
|
];
|
||||||
|
$dropSum += $drop;
|
||||||
|
$sampleSum += (int)$prev['samples'] + (int)$row['samples'];
|
||||||
|
$bucket = (int)(floor($avgSoc / 10) * 10);
|
||||||
|
$socBuckets[$bucket] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($rates) < 12 || $dropSum < 1.0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weightedRate = 0.0;
|
||||||
|
$weightSum = 0.0;
|
||||||
|
foreach ($rates as $row) {
|
||||||
|
$weightedRate += (float)$row['rate'] * (float)$row['weight'];
|
||||||
|
$weightSum += (float)$row['weight'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($weightedRate <= 0 || $weightSum <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate = $weightedRate / $weightSum;
|
||||||
|
$seconds = (int)round($currentPercent / $rate);
|
||||||
|
if ($seconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$watts = numeric_trimmed_average(array_column($rates, 'watts'), 0.1);
|
||||||
|
$confidence = min(1.0, count($rates) / 240)
|
||||||
|
* min(1.0, $dropSum / 18)
|
||||||
|
* min(1.0, count($socBuckets) / 5);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'seconds' => $seconds,
|
||||||
|
'rate_per_second' => $rate,
|
||||||
|
'drop_percent' => round($dropSum, 3),
|
||||||
|
'elapsed_seconds' => null,
|
||||||
|
'window_seconds' => null,
|
||||||
|
'sample_count' => $sampleSum,
|
||||||
|
'confidence' => round($confidence, 4),
|
||||||
|
'weight' => max(0.05, $confidence * 0.9),
|
||||||
|
'avg_watts' => $watts === null ? null : round($watts, 3),
|
||||||
|
'load_factor' => null,
|
||||||
|
'source' => 'learned_profile',
|
||||||
|
'intervals' => count($rates),
|
||||||
|
'soc_buckets' => count($socBuckets),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +1087,7 @@ function battery_power_fallback_estimate(float $percent, array $history): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function battery_remaining_estimate(array $battery, array $history, array $trendRows = []): array
|
function battery_remaining_estimate(array $battery, array $history, array $trendRows = [], array $profileRows = []): array
|
||||||
{
|
{
|
||||||
$percent = $battery['percent'] ?? null;
|
$percent = $battery['percent'] ?? null;
|
||||||
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
if ($percent === null || $percent === '' || !is_numeric($percent)) {
|
||||||
@@ -922,6 +1111,11 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$learned = battery_learned_profile_candidate($profileRows, $percent, $recentWatts);
|
||||||
|
if ($learned !== null) {
|
||||||
|
$candidates[] = $learned;
|
||||||
|
}
|
||||||
|
|
||||||
if ($candidates !== []) {
|
if ($candidates !== []) {
|
||||||
$weightedRate = 0.0;
|
$weightedRate = 0.0;
|
||||||
$weightSum = 0.0;
|
$weightSum = 0.0;
|
||||||
@@ -944,12 +1138,16 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
|
|||||||
'recent_watts' => $recentWatts === null ? null : round($recentWatts, 3),
|
'recent_watts' => $recentWatts === null ? null : round($recentWatts, 3),
|
||||||
'drop_per_hour' => round($rate * 3600, 3),
|
'drop_per_hour' => round($rate * 3600, 3),
|
||||||
'confidence' => round(array_sum(array_column($candidates, 'confidence')) / count($candidates), 3),
|
'confidence' => round(array_sum(array_column($candidates, 'confidence')) / count($candidates), 3),
|
||||||
|
'sources' => array_values(array_unique(array_column($candidates, 'source'))),
|
||||||
'windows' => array_map(static function (array $candidate): array {
|
'windows' => array_map(static function (array $candidate): array {
|
||||||
return [
|
return [
|
||||||
'minutes' => (int)round($candidate['window_seconds'] / 60),
|
'source' => $candidate['source'],
|
||||||
|
'minutes' => $candidate['window_seconds'] === null ? null : (int)round($candidate['window_seconds'] / 60),
|
||||||
'drop_percent' => $candidate['drop_percent'],
|
'drop_percent' => $candidate['drop_percent'],
|
||||||
'confidence' => $candidate['confidence'],
|
'confidence' => $candidate['confidence'],
|
||||||
'load_factor' => $candidate['load_factor'],
|
'load_factor' => $candidate['load_factor'],
|
||||||
|
'intervals' => $candidate['intervals'] ?? null,
|
||||||
|
'soc_buckets' => $candidate['soc_buckets'] ?? null,
|
||||||
];
|
];
|
||||||
}, $candidates),
|
}, $candidates),
|
||||||
];
|
];
|
||||||
@@ -2346,7 +2544,8 @@ function collect_snapshot(bool $applyFan = true): array
|
|||||||
|
|
||||||
$history = add_battery_remaining_history(sensor_history(240));
|
$history = add_battery_remaining_history(sensor_history(240));
|
||||||
$batteryTrend = battery_trend_history(24);
|
$batteryTrend = battery_trend_history(24);
|
||||||
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend);
|
$batteryProfile = battery_profile_history(45);
|
||||||
|
$battery['remaining'] = battery_remaining_estimate($battery, $history, $batteryTrend, $batteryProfile);
|
||||||
|
|
||||||
$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);
|
||||||
|
|||||||
+13
-1
@@ -699,8 +699,20 @@
|
|||||||
} else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) {
|
} else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) {
|
||||||
lines.push(`평균 CPU 전력: ${Number(remaining.avg_watts).toFixed(3)}W`);
|
lines.push(`평균 CPU 전력: ${Number(remaining.avg_watts).toFixed(3)}W`);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(remaining.sources) && remaining.sources.length > 0) {
|
||||||
|
const labels = {
|
||||||
|
recent_window: '최근 추세',
|
||||||
|
learned_profile: '장기 학습',
|
||||||
|
};
|
||||||
|
lines.push(`계산 출처: ${remaining.sources.map(source => labels[source] || source).join(', ')}`);
|
||||||
|
}
|
||||||
if (Array.isArray(remaining.windows) && remaining.windows.length > 0) {
|
if (Array.isArray(remaining.windows) && remaining.windows.length > 0) {
|
||||||
lines.push(`반영 구간: ${remaining.windows.map(row => `${row.minutes}분`).join(', ')}`);
|
lines.push(`반영 구간: ${remaining.windows.map(row => {
|
||||||
|
if (row.source === 'learned_profile') {
|
||||||
|
return `장기 ${row.intervals || 0}구간`;
|
||||||
|
}
|
||||||
|
return `${row.minutes}분`;
|
||||||
|
}).join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user