배터리 잔여 시간 예측 후보 확장
This commit is contained in:
+279
-1
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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' ? '강건 회귀' : '최근 추세';
|
||||
|
||||
Reference in New Issue
Block a user