잔여 시간 계산에 장기 학습 프로파일 반영

This commit is contained in:
seo
2026-06-26 02:11:34 +09:00
parent b6cdb0f162
commit 52fa1408bc
3 changed files with 228 additions and 13 deletions
+13 -9
View File
@@ -14,7 +14,7 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
- PWM slider 기반 수동 팬 제어
- CPU 온도, 팬 RPM, 배터리 SOC, 배터리 전압 핵심 차트
- RP1 온도, 팬 효율, CPU 전력, 잔여 시간 상세 차트는 접힘 영역에서 확인
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 30분/1시간/3시간/6시간/12시간/24시간 창으로 비교해 계산하고, CPU 전력 변화로 현재 부하를 보정
- 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 사용하고, CPU 전력 변화로 현재 부하를 보정
- 라즈베리파이 저전압/스로틀링 현재 상태, 복구 중/반복 발생 판정, 부팅 후 이력, 최근 감지 시각, 지속시간, 최근 10분 통계 표시
- WiFi client 목록과 2.4G/5G client 수 표시
- 5G 외부 WiFi 모듈이 연결 시간을 제공하지 않는 경우 서버 관측 기반 연결 시간 보정
@@ -76,13 +76,15 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다.
3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다.
4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다.
5.근 CPU 전력이 해당 창의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다.
6. 여러 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수를 기준으로 계산합니다.
7. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다.
5.대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일을 만듭니다.
6. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다.
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 알림 구조
@@ -136,7 +138,7 @@ control-wifi-observe.service
8. Reboot 버튼은 `재부팅` 단어 입력과 관리자 암호 재입력을 모두 통과한 뒤 서버 API로 재부팅을 요청합니다.
9. 시스템 상태는 `vcgencmd get_throttled`를 읽어 현재 저전압/스로틀링 여부와 부팅 후 이력, 최근 감지 시각, 현재/최근 지속시간을 표시합니다.
10. 저전압/스로틀링 상태 파일에는 최근 episode 시작/종료를 보관하고, 최근 10분 발생 횟수, 감지 누적시간, 감지 비율을 계산합니다.
11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세를 여러 시간창으로 나눠 평가하고, 현재 CPU 전력 변화로 보정합니다.
11. 배터리 잔여 시간은 최근 24시간 SOC 방전 추세와 최대 45일 장기 학습 프로파일을 함께 평가하고, 현재 CPU 전력 변화로 보정합니다.
12. 배터리 SOC 또는 System Notice 조건이 맞으면 Control이 HA webhook으로 알림을 발송하고 `ha_notify_logs`에 결과를 저장합니다.
13. 대시보드는 최근 HA 알림 성공/실패 이력을 `ha_notify_logs`에서 읽어 진단 접힘 영역에 표시합니다.
14. 대시보드 첫 화면에는 팬/전원/스로틀링/WiFi/핵심 차트를 우선 배치하고, 상세 차트, 프로세스 후보, 사용자 서비스, HA 알림 이력, dmesg는 접힘 영역으로 분리합니다.
@@ -159,7 +161,9 @@ control-wifi-observe.service
- `send_battery_low_notify_if_needed()`: 배터리 SOC 20% 이하 알림 발송
- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회
- `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 전력 평균으로 잔여 시간 대체 계산
- `bin/ha_notify_channel.php`: Android 알림 채널 제거 명령 CLI
- `bin/wifi_observe.php`: 화면 접속 없이 `wifi_data()`를 호출해 5G 관측 세션을 선제 갱신
@@ -181,7 +185,7 @@ control-wifi-observe.service
- 센서 수집 주기와 DB 증가량을 확인합니다.
- 배터리팩 변경 시 `/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 경로를 확인합니다.
- 2.4G 내장 WiFi는 `hostapd-24g.service` 개별 restart 직후 일부 IoT 단말이 WPA/EAPOL 재협상 루프에 들어갈 수 있으므로, 안정화된 상태에서는 개별 restart를 피하고 필요 시 전체 reboot 또는 채널 변경으로 재초기화합니다.
- 5G WiFi 연결 시간은 외부 모듈이 값을 제공하지 않을 때 서버가 처음 감지한 시각 기준으로 계산합니다. 현재 5G 목록에서 MAC이 사라지면 관측 세션을 즉시 종료하므로 재연결 시 0부터 다시 누적됩니다.
+202 -3
View File
@@ -793,6 +793,91 @@ function battery_trend_history(int $hours = 24): array
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
{
if (count($trendRows) < 6) {
@@ -862,6 +947,110 @@ function battery_trend_candidate(array $trendRows, float $currentPercent, int $w
'weight' => max(0.05, $confidence),
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 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;
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 !== []) {
$weightedRate = 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),
'drop_per_hour' => round($rate * 3600, 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 {
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'],
'confidence' => $candidate['confidence'],
'load_factor' => $candidate['load_factor'],
'intervals' => $candidate['intervals'] ?? null,
'soc_buckets' => $candidate['soc_buckets'] ?? null,
];
}, $candidates),
];
@@ -2346,7 +2544,8 @@ function collect_snapshot(bool $applyFan = true): array
$history = add_battery_remaining_history(sensor_history(240));
$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);
$fanSpike = fan_spike_analysis($history, $fan, $system, $processes);
+13 -1
View File
@@ -699,8 +699,20 @@
} else if (remaining.avg_watts !== undefined && remaining.avg_watts !== null) {
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) {
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');