배터리 잔여 시간 예측 정밀도 개선
This commit is contained in:
@@ -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
@@ -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),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user