배터리 잔여 시간 예측 정밀도 개선

This commit is contained in:
seo
2026-06-26 02:46:35 +09:00
parent b0714f9ff2
commit 80502d753d
3 changed files with 234 additions and 8 deletions
+13 -7
View File
@@ -79,18 +79,21 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를
1. `sensor_logs`의 최근 24시간 배터리 기록을 1분 단위로 SQL 집계합니다. 1. `sensor_logs`의 최근 24시간 배터리 기록을 1분 단위로 SQL 집계합니다.
2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다. 2. 30분, 1시간, 3시간, 6시간, 12시간, 24시간 창을 각각 검사합니다.
3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다. 3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다.
4. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다. 4. 45분, 2시간, 4시간, 8시간, 16시간, 24시간 창은 가중 선형회귀로 SOC 기울기를 다시 계산합니다.
5. 최대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일을 만듭니다. 5. 회귀 후보는 중앙절대편차 기준으로 잔차가 큰 SOC 튐을 제거한 뒤 한 번 더 회귀해 기울기를 보정합니다.
6. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다. 6. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다.
7.근 CPU 전력이 각 후보 구간의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다. 7.대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일을 만듭니다.
8. 단기 후보와 장기 학습 후보는 신뢰도 가중치로 합산합니다. 신뢰도는 관측 시간, SOC 감소폭, 샘플 수, SOC 구간 다양성을 기준으로 계산합니다. 8. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다.
9. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. 9. 최근 CPU 전력이 각 후보 구간의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다.
10. 단기 중앙값 후보, 강건 회귀 후보, 장기 학습 후보는 신뢰도와 안정도 가중치로 합산합니다.
11. 후보끼리 방전 속도가 크게 어긋나면 중앙값 기준으로 튄 후보를 제외하거나 가중치를 낮춥니다.
12. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다.
WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다. WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다.
대시보드의 잔여 시간 값에는 Control UI 스타일의 커스텀 계산 근거 팝오버가 붙습니다. 기본 브라우저 `title` tooltip은 사용하지 않으며, 마우스 hover 또는 키보드 focus 중에도 상태 갱신이 들어오면 팝오버 내용과 위치가 즉시 갱신됩니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다. 대시보드의 잔여 시간 값에는 Control UI 스타일의 커스텀 계산 근거 팝오버가 붙습니다. 기본 브라우저 `title` tooltip은 사용하지 않으며, 마우스 hover 또는 키보드 focus 중에도 상태 갱신이 들어오면 팝오버 내용과 위치가 즉시 갱신됩니다. `SOC 다중 시간창 추세`로 표시되면 실제 DB 방전 속도 기반이며, 계산 출처에 `장기 학습`이 포함되면 누적 기록에서 학습한 SOC/부하 구간별 방전 경험도 반영된 상태입니다. `배터리 용량과 최근 전력 평균`이면 SOC 추세가 부족해 용량 기반 fallback이 사용된 상태입니다.
팝오버는 계산 잔여 시간, 시간당 SOC 감소율, 추세 신뢰도, 최근 CPU 전력, 계산 출처, 반영 구간을 표시합니다. 각 후보별로 최근 30분/1시간/3시간/6시간/12시간/24시간 감소율, 신뢰도, 부하 보정 배율을 표시하고, 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다. 팝오버는 계산 잔여 시간, 시간당 SOC 감소율, 추세 신뢰도, 최근 CPU 전력, 계산 출처, 반영 구간을 표시합니다. 각 후보별로 최근 30분/1시간/3시간/6시간/12시간/24시간 감소율, 신뢰도, 부하 보정 배율을 표시하고, 강건 회귀 후보는 튐 제거 후 남은 유효점 비율과 후보 안정도를 함께 표시합니다. 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다.
잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다.
@@ -191,6 +194,9 @@ control-wifi-observe.service
- `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회 - `ha_notify_log_rows()`: 대시보드 HA 알림 이력 조회
- `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계 - `battery_trend_history()`: 최근 24시간 배터리 기록을 1분 단위로 집계
- `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관 - `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관
- `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거
- `battery_regression_trend_candidate()`: 1분 SOC 집계를 강건 회귀로 분석해 정밀 방전 속도 후보 계산
- `refine_battery_candidates()`: 후보 방전 속도의 중앙값에서 크게 벗어난 값을 제외하거나 가중치 축소
- `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산 - `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산
- `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산 - `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산
- `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산 - `battery_power_fallback_estimate()`: SOC 추세가 부족할 때 배터리 용량과 최근 CPU 전력 평균으로 잔여 시간 대체 계산
+213
View File
@@ -737,6 +737,67 @@ function clamp_float(float $value, float $min, float $max): float
return max($min, min($max, $value)); return max($min, min($max, $value));
} }
function weighted_linear_regression(array $points): ?array
{
$weightSum = 0.0;
$xSum = 0.0;
$ySum = 0.0;
foreach ($points as $point) {
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
$x = (float)$point['x'];
$y = (float)$point['y'];
$weightSum += $weight;
$xSum += $x * $weight;
$ySum += $y * $weight;
}
if ($weightSum <= 0 || count($points) < 3) {
return null;
}
$xMean = $xSum / $weightSum;
$yMean = $ySum / $weightSum;
$cov = 0.0;
$var = 0.0;
foreach ($points as $point) {
$weight = max(0.0001, (float)($point['weight'] ?? 1.0));
$dx = (float)$point['x'] - $xMean;
$dy = (float)$point['y'] - $yMean;
$cov += $weight * $dx * $dy;
$var += $weight * $dx * $dx;
}
if ($var <= 0) {
return null;
}
$slope = $cov / $var;
return [
'slope' => $slope,
'intercept' => $yMean - ($slope * $xMean),
'weight_sum' => $weightSum,
];
}
function numeric_mad(array $values): ?float
{
$median = numeric_median($values);
if ($median === null) {
return null;
}
$deviations = [];
foreach ($values as $value) {
if ($value !== null && $value !== '' && is_numeric($value)) {
$deviations[] = abs((float)$value - $median);
}
}
return numeric_median($deviations);
}
function battery_trend_history(int $hours = 24): array function battery_trend_history(int $hours = 24): array
{ {
$hours = max(1, min(48, $hours)); $hours = max(1, min(48, $hours));
@@ -951,6 +1012,112 @@ function battery_trend_candidate(array $trendRows, float $currentPercent, int $w
]; ];
} }
function battery_regression_trend_candidate(array $trendRows, float $currentPercent, int $windowSeconds, ?float $recentWatts): ?array
{
if (count($trendRows) < 10) {
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 < 10) {
return null;
}
$firstTime = (int)$rows[0]['time'];
$lastRowTime = (int)$rows[$count - 1]['time'];
$elapsed = max(1, $lastRowTime - $firstTime);
if ($elapsed < 900) {
return null;
}
$points = [];
$lastIndex = max(1, $count - 1);
foreach ($rows as $index => $row) {
$soc = (float)$row['battery_percent'];
$samples = max(1, (int)($row['samples'] ?? 1));
$recencyWeight = 0.65 + (0.35 * ($index / $lastIndex));
$sampleWeight = min(1.0, sqrt($samples) / 4);
$points[] = [
'x' => max(0, (int)$row['time'] - $firstTime),
'y' => $soc,
'weight' => $recencyWeight * max(0.35, $sampleWeight),
];
}
$fit = weighted_linear_regression($points);
if ($fit === null || (float)$fit['slope'] >= 0) {
return null;
}
$residuals = [];
foreach ($points as $point) {
$residuals[] = (float)$point['y'] - ((float)$fit['intercept'] + ((float)$fit['slope'] * (float)$point['x']));
}
$mad = numeric_mad($residuals);
$residualLimit = max(0.08, ($mad ?? 0.0) * 3.5);
$filtered = [];
foreach ($points as $index => $point) {
if (abs($residuals[$index]) <= $residualLimit) {
$filtered[] = $point;
}
}
if (count($filtered) >= max(8, (int)floor($count * 0.62))) {
$refit = weighted_linear_regression($filtered);
if ($refit !== null && (float)$refit['slope'] < 0) {
$fit = $refit;
$points = $filtered;
}
}
$rate = -(float)$fit['slope'];
$drop = $rate * $elapsed;
$minDrop = $windowSeconds <= 3600 ? 0.10 : 0.18;
if ($rate <= 0 || $drop < $minDrop) {
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.7, 1.65) - 1) * 0.38);
$rate *= $loadFactor;
}
$seconds = (int)round($currentPercent / $rate);
if ($seconds <= 0) {
return null;
}
$keptRatio = count($points) / $count;
$confidence = min(1.0, $elapsed / $windowSeconds)
* min(1.0, $drop / 1.0)
* min(1.0, $count / 120)
* clamp_float($keptRatio, 0.45, 1.0);
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 * 1.18),
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
'load_factor' => round($loadFactor, 3),
'source' => 'robust_regression',
'kept_ratio' => round($keptRatio, 3),
];
}
function battery_learned_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array function battery_learned_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
{ {
if (count($profileRows) < 24) { if (count($profileRows) < 24) {
@@ -1054,6 +1221,43 @@ function battery_learned_profile_candidate(array $profileRows, float $currentPer
]; ];
} }
function refine_battery_candidates(array $candidates): array
{
if (count($candidates) < 3) {
return $candidates;
}
$rates = array_values(array_filter(
array_map(static fn(array $candidate): float => (float)($candidate['rate_per_second'] ?? 0), $candidates),
static fn(float $rate): bool => $rate > 0
));
$medianRate = numeric_median($rates);
if ($medianRate === null || $medianRate <= 0) {
return $candidates;
}
$refined = [];
foreach ($candidates as $candidate) {
$rate = (float)($candidate['rate_per_second'] ?? 0);
if ($rate <= 0) {
continue;
}
$ratio = $rate / $medianRate;
if ($ratio < 0.38 || $ratio > 2.65) {
continue;
}
$distance = abs(log(max(0.001, $ratio)));
$stability = 1 / (1 + ($distance * 1.85));
$candidate['weight'] = max(0.03, (float)$candidate['weight'] * $stability);
$candidate['stability'] = round($stability, 3);
$refined[] = $candidate;
}
return $refined !== [] ? $refined : $candidates;
}
function battery_power_fallback_estimate(float $percent, array $history): array function battery_power_fallback_estimate(float $percent, array $history): array
{ {
$wattValues = []; $wattValues = [];
@@ -1110,11 +1314,18 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
$candidates[] = $candidate; $candidates[] = $candidate;
} }
} }
foreach ([2700, 7200, 14400, 28800, 57600, 86400] as $windowSeconds) {
$candidate = battery_regression_trend_candidate($trendRows, $percent, $windowSeconds, $recentWatts);
if ($candidate !== null) {
$candidates[] = $candidate;
}
}
$learned = battery_learned_profile_candidate($profileRows, $percent, $recentWatts); $learned = battery_learned_profile_candidate($profileRows, $percent, $recentWatts);
if ($learned !== null) { if ($learned !== null) {
$candidates[] = $learned; $candidates[] = $learned;
} }
$candidates = refine_battery_candidates($candidates);
if ($candidates !== []) { if ($candidates !== []) {
$weightedRate = 0.0; $weightedRate = 0.0;
@@ -1148,6 +1359,8 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
'load_factor' => $candidate['load_factor'], 'load_factor' => $candidate['load_factor'],
'intervals' => $candidate['intervals'] ?? null, 'intervals' => $candidate['intervals'] ?? null,
'soc_buckets' => $candidate['soc_buckets'] ?? null, 'soc_buckets' => $candidate['soc_buckets'] ?? null,
'kept_ratio' => $candidate['kept_ratio'] ?? null,
'stability' => $candidate['stability'] ?? null,
]; ];
}, $candidates), }, $candidates),
]; ];
+8 -1
View File
@@ -714,6 +714,7 @@
if (Array.isArray(remaining.sources) && remaining.sources.length > 0) { if (Array.isArray(remaining.sources) && remaining.sources.length > 0) {
const labels = { const labels = {
recent_window: '최근 추세', recent_window: '최근 추세',
robust_regression: '강건 회귀',
learned_profile: '장기 학습', learned_profile: '장기 학습',
}; };
lines.push(`계산 출처: ${remaining.sources.map(source => labels[source] || source).join(', ')}`); lines.push(`계산 출처: ${remaining.sources.map(source => labels[source] || source).join(', ')}`);
@@ -723,6 +724,9 @@
if (row.source === 'learned_profile') { if (row.source === 'learned_profile') {
return `장기 ${row.intervals || 0}구간`; return `장기 ${row.intervals || 0}구간`;
} }
if (row.source === 'robust_regression') {
return `회귀 ${row.minutes}`;
}
return `${row.minutes}`; return `${row.minutes}`;
}).join(', ')}`); }).join(', ')}`);
lines.push(''); lines.push('');
@@ -732,7 +736,10 @@
lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`); lines.push(`- 장기 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / 누적감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%`);
} else { } else {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`; const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
lines.push(`- 최근 ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}`); const prefix = row.source === 'robust_regression' ? '강건 회귀' : '최근 추세';
const kept = row.kept_ratio === null || row.kept_ratio === undefined ? '' : ` / 유효점 ${(Number(row.kept_ratio) * 100).toFixed(0)}%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- ${prefix} ${row.minutes}분: 감소 ${Number(row.drop_percent || 0).toFixed(3)}% / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}`);
} }
}); });
} }