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

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
+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');