diff --git a/README.md b/README.md index cb04101..e1e6feb 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,21 @@ Control은 라즈베리파이/리눅스 호스트의 팬과 시스템 상태를 3. 각 창의 시작/끝 SOC는 평균 한 점이 아니라 가장자리 구간의 중앙값으로 잡아 순간 튐을 줄입니다. 4. 45분, 2시간, 4시간, 8시간, 16시간, 24시간 창은 가중 선형회귀로 SOC 기울기를 다시 계산합니다. 5. 회귀 후보는 중앙절대편차 기준으로 잔차가 큰 SOC 튐을 제거한 뒤 한 번 더 회귀해 기울기를 보정합니다. -6. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다. -7. 최대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일을 만듭니다. -8. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다. -9. 최근 CPU 전력이 각 후보 구간의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다. -10. 단기 중앙값 후보, 강건 회귀 후보, 장기 학습 후보는 신뢰도와 안정도 가중치로 합산합니다. -11. 후보끼리 방전 속도가 크게 어긋나면 중앙값 기준으로 튄 후보를 제외하거나 가중치를 낮춥니다. -12. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. +6. 전압도 1분 집계 기록에서 가중 회귀로 기울기를 계산하고, 현재 전압에서 동적 하한 전압까지 남은 시간을 별도 후보로 만듭니다. +7. SOC가 충분히 줄어든 방전 구간만 후보로 사용하고, 충전 또는 회복처럼 보이는 구간은 제외합니다. +8. 최대 45일 기록은 약 5분 간격으로 샘플링해 SOC 구간별 장기 방전 프로파일과 SOC 1%당 실제 사용 Wh를 학습합니다. +9. 장기 프로파일은 현재 SOC와 가까운 구간, 최근에 관측된 구간, 샘플 수가 많은 구간에 더 높은 가중치를 줍니다. +10. 최근 CPU 전력이 각 후보 구간의 평균 전력보다 높거나 낮으면 방전 속도에 완만하게 반영해 현재 부하 변화를 보정합니다. +11. 단기 중앙값 후보, 강건 회귀 후보, 전압 기울기 후보, 에너지 학습 후보, 용량 전력 후보, 장기 학습 후보를 신뢰도와 안정도 가중치로 합산합니다. +12. 후보끼리 방전 속도가 크게 어긋나면 중앙값 기준으로 튄 후보를 제외하거나 가중치를 낮춥니다. +13. 최종 신뢰도는 후보별 신뢰도 가중 평균에 후보 간 방전 속도 분산을 반영해 계산합니다. +14. SOC 추세가 부족하면 마지막 대체값으로 배터리 용량 `BATTERY_CAPACITY_WH`와 최근 CPU 전력의 절사 평균을 사용합니다. WebSocket 장기 실행 프로세스에서는 1분 단위 단기 집계 결과를 55초 동안, 약 5분 간격 장기 학습 샘플을 10분 동안 재사용해 DB 부담을 줄입니다. 장기 학습 샘플은 파일 캐시로도 보관해 다른 PHP 프로세스에서도 빠르게 재사용합니다. 대시보드의 잔여 시간 값에는 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 1%당 실제 Wh를, 장기 학습 후보는 반영된 학습 구간 수와 SOC 구간 수를 함께 표시합니다. 잔여 시간 차트의 마지막 지점은 현재 snapshot의 `battery.remaining.seconds`와 동기화합니다. DB에 저장된 마지막 센서 기록이 몇 초 늦어도 좌측 상태 영역과 차트의 최신 잔여 시간이 서로 다른 값을 보여주지 않도록, API 응답 history 끝에 현재 snapshot row를 반영합니다. @@ -196,6 +198,9 @@ control-wifi-observe.service - `battery_profile_history()`: 최대 45일 배터리 기록을 약 5분 간격으로 샘플링하고 파일 캐시로 보관 - `weighted_linear_regression()`, `numeric_mad()`: SOC 기울기 계산과 회귀 잔차 튐 제거 - `battery_regression_trend_candidate()`: 1분 SOC 집계를 강건 회귀로 분석해 정밀 방전 속도 후보 계산 +- `battery_voltage_trend_candidate()`: 전압 기울기와 동적 하한 전압으로 잔여 시간 후보 계산 +- `battery_energy_profile_candidate()`: 장기 기록에서 SOC 1%당 실제 사용 Wh를 학습해 잔여 시간 후보 계산 +- `battery_capacity_power_candidate()`: 설정 배터리 용량과 최근 전력으로 용량 기반 후보 계산 - `refine_battery_candidates()`: 후보 방전 속도의 중앙값에서 크게 벗어난 값을 제외하거나 가중치 축소 - `battery_learned_profile_candidate()`: 누적 기록에서 SOC/부하 구간별 장기 방전 프로파일 후보 계산 - `battery_remaining_estimate()`: SOC 다중 시간창 방전 추세, 장기 학습 프로파일, 현재 부하 보정으로 잔여 시간 계산 diff --git a/public/api.php b/public/api.php index bb82dd2..2cb9320 100644 --- a/public/api.php +++ b/public/api.php @@ -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), ]; diff --git a/public/assets/app.js b/public/assets/app.js index 06b8e9c..5d7e0d1 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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' ? '강건 회귀' : '최근 추세';