배터리 잔여 시간 예측 후보 확장

This commit is contained in:
seo
2026-06-26 02:53:07 +09:00
parent 700258c7fa
commit 41b1ba5336
3 changed files with 325 additions and 9 deletions
+279 -1
View File
@@ -1118,6 +1118,136 @@ function battery_regression_trend_candidate(array $trendRows, float $currentPerc
];
}
function battery_voltage_floor(float $currentVoltage): float
{
if ($currentVoltage >= 3.55) {
return 3.28;
}
if ($currentVoltage >= 3.35) {
return 3.18;
}
return 3.05;
}
function battery_voltage_trend_candidate(array $trendRows, float $currentPercent, ?float $currentVoltage, int $windowSeconds, ?float $recentWatts): ?array
{
if ($currentVoltage === null || $currentVoltage <= 0 || 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_voltage'] ?? null);
}));
$count = count($rows);
if ($count < 10) {
return null;
}
$firstTime = (int)$rows[0]['time'];
$elapsed = max(1, (int)$rows[$count - 1]['time'] - $firstTime);
if ($elapsed < 1200) {
return null;
}
$points = [];
$lastIndex = max(1, $count - 1);
foreach ($rows as $index => $row) {
$voltage = (float)$row['battery_voltage'];
if ($voltage < 2.7 || $voltage > 4.5) {
continue;
}
$samples = max(1, (int)($row['samples'] ?? 1));
$recencyWeight = 0.7 + (0.3 * ($index / $lastIndex));
$sampleWeight = min(1.0, sqrt($samples) / 4);
$points[] = [
'x' => max(0, (int)$row['time'] - $firstTime),
'y' => $voltage,
'weight' => $recencyWeight * max(0.35, $sampleWeight),
];
}
if (count($points) < 10) {
return null;
}
$fit = weighted_linear_regression($points);
if ($fit === null || (float)$fit['slope'] >= -0.0000003) {
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.006, ($mad ?? 0.0) * 3.2);
$filtered = [];
foreach ($points as $index => $point) {
if (abs($residuals[$index]) <= $residualLimit) {
$filtered[] = $point;
}
}
if (count($filtered) >= max(8, (int)floor(count($points) * 0.58))) {
$refit = weighted_linear_regression($filtered);
if ($refit !== null && (float)$refit['slope'] < 0) {
$fit = $refit;
$points = $filtered;
}
}
$voltsPerSecond = -(float)$fit['slope'];
$voltageFloor = battery_voltage_floor($currentVoltage);
$usableVolts = $currentVoltage - $voltageFloor;
if ($voltsPerSecond <= 0 || $usableVolts < 0.015) {
return null;
}
$seconds = (int)round($usableVolts / $voltsPerSecond);
if ($seconds <= 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.75, 1.55) - 1) * 0.28);
$seconds = (int)round($seconds / $loadFactor);
}
if ($seconds <= 0) {
return null;
}
$rate = $currentPercent / $seconds;
$keptRatio = count($points) / $count;
$confidence = min(1.0, $elapsed / $windowSeconds)
* min(1.0, (($currentVoltage - (float)$rows[0]['battery_voltage']) * -1) / 0.08)
* min(1.0, $count / 120)
* clamp_float($keptRatio, 0.4, 1.0);
return [
'seconds' => $seconds,
'rate_per_second' => $rate,
'drop_percent' => round($currentPercent, 3),
'elapsed_seconds' => $elapsed,
'window_seconds' => $windowSeconds,
'sample_count' => $count,
'confidence' => round(max(0.03, $confidence), 4),
'weight' => max(0.03, $confidence * 0.72),
'avg_watts' => $windowWatts === null ? null : round($windowWatts, 3),
'load_factor' => round($loadFactor, 3),
'source' => 'voltage_slope',
'kept_ratio' => round($keptRatio, 3),
'voltage_floor' => round($voltageFloor, 3),
'volts_per_hour' => round($voltsPerSecond * 3600, 4),
];
}
function battery_learned_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
{
if (count($profileRows) < 24) {
@@ -1221,6 +1351,123 @@ function battery_learned_profile_candidate(array $profileRows, float $currentPer
];
}
function battery_capacity_power_candidate(float $currentPercent, ?float $recentWatts): ?array
{
if (BATTERY_CAPACITY_WH <= 0 || $recentWatts === null || $recentWatts <= 0) {
return null;
}
$remainingWh = BATTERY_CAPACITY_WH * ($currentPercent / 100);
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
if ($seconds <= 0) {
return null;
}
return [
'seconds' => $seconds,
'rate_per_second' => $currentPercent / $seconds,
'drop_percent' => round($currentPercent, 3),
'elapsed_seconds' => null,
'window_seconds' => null,
'sample_count' => null,
'confidence' => 0.34,
'weight' => 0.18,
'avg_watts' => round($recentWatts, 3),
'load_factor' => 1.0,
'source' => 'capacity_power_model',
'capacity_wh' => BATTERY_CAPACITY_WH,
];
}
function battery_energy_profile_candidate(array $profileRows, float $currentPercent, ?float $recentWatts): ?array
{
if ($recentWatts === null || $recentWatts <= 0 || count($profileRows) < 24) {
return null;
}
$now = time();
$weightedWhPerPercent = 0.0;
$weightSum = 0.0;
$dropSum = 0.0;
$intervals = 0;
$socBuckets = [];
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 > 1200) {
continue;
}
if (!is_numeric($prev['battery_percent'] ?? null) || !is_numeric($row['battery_percent'] ?? null)) {
continue;
}
$startSoc = (float)$prev['battery_percent'];
$endSoc = (float)$row['battery_percent'];
$drop = $startSoc - $endSoc;
if ($drop < 0.03 || $drop > 6.0) {
continue;
}
$avgWatts = numeric_trimmed_average([$prev['cpu_watts'] ?? null, $row['cpu_watts'] ?? null], 0.0);
if ($avgWatts === null || $avgWatts <= 0) {
continue;
}
$avgSoc = ($startSoc + $endSoc) / 2;
$socDistance = abs($currentPercent - $avgSoc);
$socWeight = 1 / (1 + ($socDistance / 22));
$ageDays = max(0.0, ($now - (int)$row['time']) / 86400);
$recencyWeight = 1 / (1 + ($ageDays / 40));
$sampleWeight = min(1.0, max(1, ((int)$prev['samples'] + (int)$row['samples']) / 2) / 24);
$weight = max(0.01, $socWeight * $recencyWeight * $sampleWeight);
$whPerPercent = (($avgWatts * $elapsed) / 3600) / $drop;
if ($whPerPercent <= 0 || $whPerPercent > 4.0) {
continue;
}
$weightedWhPerPercent += $whPerPercent * $weight;
$weightSum += $weight;
$dropSum += $drop;
$intervals++;
$socBuckets[(int)(floor($avgSoc / 10) * 10)] = true;
}
if ($intervals < 12 || $dropSum < 1.0 || $weightSum <= 0) {
return null;
}
$whPerPercent = $weightedWhPerPercent / $weightSum;
$remainingWh = $whPerPercent * $currentPercent;
$seconds = (int)round(($remainingWh / $recentWatts) * 3600);
if ($seconds <= 0) {
return null;
}
$confidence = min(1.0, $intervals / 180)
* min(1.0, $dropSum / 14)
* min(1.0, count($socBuckets) / 5);
return [
'seconds' => $seconds,
'rate_per_second' => $currentPercent / $seconds,
'drop_percent' => round($dropSum, 3),
'elapsed_seconds' => null,
'window_seconds' => null,
'sample_count' => $intervals,
'confidence' => round($confidence, 4),
'weight' => max(0.04, $confidence * 0.82),
'avg_watts' => round($recentWatts, 3),
'load_factor' => 1.0,
'source' => 'energy_profile',
'intervals' => $intervals,
'soc_buckets' => count($socBuckets),
'wh_per_percent' => round($whPerPercent, 4),
];
}
function refine_battery_candidates(array $candidates): array
{
if (count($candidates) < 3) {
@@ -1304,6 +1551,9 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
}
$percent = max(0.0, min(100.0, (float)$percent));
$currentVoltage = isset($battery['voltage']) && is_numeric($battery['voltage'])
? (float)$battery['voltage']
: null;
$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);
@@ -1320,19 +1570,35 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
$candidates[] = $candidate;
}
}
foreach ([3600, 10800, 21600, 43200, 86400] as $windowSeconds) {
$candidate = battery_voltage_trend_candidate($trendRows, $percent, $currentVoltage, $windowSeconds, $recentWatts);
if ($candidate !== null) {
$candidates[] = $candidate;
}
}
$learned = battery_learned_profile_candidate($profileRows, $percent, $recentWatts);
if ($learned !== null) {
$candidates[] = $learned;
}
$energyProfile = battery_energy_profile_candidate($profileRows, $percent, $recentWatts);
if ($energyProfile !== null) {
$candidates[] = $energyProfile;
}
$capacityPower = battery_capacity_power_candidate($percent, $recentWatts);
if ($capacityPower !== null) {
$candidates[] = $capacityPower;
}
$candidates = refine_battery_candidates($candidates);
if ($candidates !== []) {
$weightedRate = 0.0;
$weightedConfidence = 0.0;
$weightSum = 0.0;
foreach ($candidates as $candidate) {
$weight = (float)$candidate['weight'];
$weightedRate += (float)$candidate['rate_per_second'] * $weight;
$weightedConfidence += (float)$candidate['confidence'] * $weight;
$weightSum += $weight;
}
@@ -1340,6 +1606,12 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
$rate = $weightedRate / $weightSum;
$seconds = (int)round($percent / $rate);
$avgWatts = numeric_trimmed_average(array_column($candidates, 'avg_watts'), 0.0);
$rates = array_column($candidates, 'rate_per_second');
$medianRate = numeric_median($rates);
$rateSpread = 0.0;
if ($medianRate !== null && $medianRate > 0) {
$rateSpread = (numeric_mad($rates) ?? 0.0) / $medianRate;
}
return [
'display' => human_remaining_seconds($seconds),
@@ -1348,7 +1620,9 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
'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),
'confidence' => round(($weightedConfidence / $weightSum) * (1 / (1 + $rateSpread)), 3),
'candidate_count' => count($candidates),
'rate_spread' => round($rateSpread, 4),
'sources' => array_values(array_unique(array_column($candidates, 'source'))),
'windows' => array_map(static function (array $candidate): array {
return [
@@ -1361,6 +1635,10 @@ function battery_remaining_estimate(array $battery, array $history, array $trend
'soc_buckets' => $candidate['soc_buckets'] ?? null,
'kept_ratio' => $candidate['kept_ratio'] ?? null,
'stability' => $candidate['stability'] ?? null,
'voltage_floor' => $candidate['voltage_floor'] ?? null,
'volts_per_hour' => $candidate['volts_per_hour'] ?? null,
'wh_per_percent' => $candidate['wh_per_percent'] ?? null,
'capacity_wh' => $candidate['capacity_wh'] ?? null,
];
}, $candidates),
];
+33
View File
@@ -706,6 +706,12 @@
if (remaining.confidence !== undefined && remaining.confidence !== null) {
lines.push(`추세 신뢰도: ${(Number(remaining.confidence) * 100).toFixed(0)}%`);
}
if (remaining.candidate_count !== undefined && remaining.candidate_count !== null) {
const spread = remaining.rate_spread === undefined || remaining.rate_spread === null
? ''
: ` / 후보 분산 ${(Number(remaining.rate_spread) * 100).toFixed(1)}%`;
lines.push(`후보 수: ${remaining.candidate_count}${spread}`);
}
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) {
@@ -715,6 +721,9 @@
const labels = {
recent_window: '최근 추세',
robust_regression: '강건 회귀',
voltage_slope: '전압 기울기',
energy_profile: '에너지 학습',
capacity_power_model: '용량 전력 모델',
learned_profile: '장기 학습',
};
lines.push(`계산 출처: ${remaining.sources.map(source => labels[source] || source).join(', ')}`);
@@ -727,6 +736,15 @@
if (row.source === 'robust_regression') {
return `회귀 ${row.minutes}`;
}
if (row.source === 'voltage_slope') {
return `전압 ${row.minutes}`;
}
if (row.source === 'energy_profile') {
return `에너지 ${row.intervals || 0}구간`;
}
if (row.source === 'capacity_power_model') {
return '용량 모델';
}
return `${row.minutes}`;
}).join(', ')}`);
lines.push('');
@@ -734,6 +752,21 @@
remaining.windows.forEach(row => {
if (row.source === 'learned_profile') {
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 if (row.source === 'energy_profile') {
const wh = row.wh_per_percent === null || row.wh_per_percent === undefined ? '-' : `${Number(row.wh_per_percent).toFixed(4)}Wh/%`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 에너지 학습: ${row.intervals || 0}구간 / SOC대 ${row.soc_buckets || 0}개 / ${wh} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
} else if (row.source === 'capacity_power_model') {
const capacity = row.capacity_wh === null || row.capacity_wh === undefined ? '-' : `${Number(row.capacity_wh).toFixed(2)}Wh`;
const stability = row.stability === null || row.stability === undefined ? '' : ` / 안정도 ${(Number(row.stability) * 100).toFixed(0)}%`;
lines.push(`- 용량 전력 모델: ${capacity} / 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}%${stability}`);
} else if (row.source === 'voltage_slope') {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
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)}%`;
const floor = row.voltage_floor === null || row.voltage_floor === undefined ? '' : ` / 하한 ${Number(row.voltage_floor).toFixed(3)}V`;
const volts = row.volts_per_hour === null || row.volts_per_hour === undefined ? '' : ` / ${Number(row.volts_per_hour).toFixed(4)}V/h`;
lines.push(`- 전압 ${row.minutes}분: 신뢰도 ${(Number(row.confidence || 0) * 100).toFixed(0)}% / 부하보정 ${load}${kept}${stability}${floor}${volts}`);
} else {
const load = row.load_factor === null || row.load_factor === undefined ? '-' : `${Number(row.load_factor).toFixed(3)}x`;
const prefix = row.source === 'robust_regression' ? '강건 회귀' : '최근 추세';