POSA_LEAKSMS/tests/forecast_test.php
cw2007 031a0c0fdf feat: 대시보드 예보·대응 정보 (예방 신호 + 조치 가이드)
기존 측정 데이터만으로 "경보가 터지기 전"을 보여주고, 위험 시 무엇을 할지
안내한다. 새 센서·ML·DB 스키마 변경 없음(폐쇄망 정합).

신규 php/forecast.php — 순수 계산 함수(DB·출력 없음, 단위테스트 용이):
- dew_point_c(Magnus 이슬점), linear_trend(최소자승 회귀), threshold_eta_min
  (임계 도달 예상 분), headroom_state(여유 3단계), forecast_metric/freshness,
  risk_items(대응 섹션용 정규화 위험 목록).
tests/forecast_test.php — 31 검사(이슬점 알려진값·회귀·ETA·여유·신선도·위험 엣지).

config.php — FORECAST_*(지평/최소점/근접여유/급변 기준) + RESPONSE_PLAYBOOKS
(상태별 대응 체크리스트) + RESPONSE_CONTACTS. 전부 config.local.php 오버라이드.

dashboard.php — 센서별 최근점으로 예보 계산 →
- 카드 예보줄(상시): 임계 여유·추세 ETA·이슬점 결로·변화율(근접/초과만 강조).
- 대응 필요 섹션(위험 시만): 조건·ETA + 상태별 권장 조치 체크리스트 + 담당자.
기존 offline 판정을 단일 진실원으로 사용, 누수 노드 중복 없음.

app.css — .forecast-chip(near/over 상태 3중 인코딩)·.response-panel·.playbook·
.contact (토큰 기반, 라이트/다크 자동). run_tests.sh 에 forecast_test 등록.

검증: forecast_test 31/0, php -l clean(7페이지+forecast), 헤드리스 렌더 확인.
2026-07-05 14:02:48 +09:00

100 lines
5.6 KiB
PHP

<?php
// =============================================================================
// forecast_test.php — forecast.php 순수 함수 단위 테스트 (하드웨어/DB 불필요)
// 실행: php tests/forecast_test.php
// =============================================================================
require_once __DIR__ . '/../php/forecast.php';
$tests = 0;
$fails = 0;
function ok(bool $cond, string $msg): void {
global $tests, $fails;
$tests++;
if ($cond) {
echo " PASS $msg\n";
} else {
$fails++;
echo " FAIL $msg\n";
}
}
function approx(?float $a, ?float $b, float $eps = 0.2): bool {
if ($a === null || $b === null) return false;
return abs($a - $b) <= $eps;
}
echo "== dew_point_c ==\n";
ok(approx(dew_point_c(25.0, 50.0), 13.86, 0.3), "T25 RH50 -> Td~13.9");
ok(approx(dew_point_c(20.0, 100.0), 20.0, 0.2), "RH100 -> Td~T");
ok(dew_point_c(20.0, 0.0) === null, "RH0 -> null");
ok(dew_point_c(20.0, 150.0) === null, "RH>100 -> null");
ok(dew_point_c(null, 50.0) === null, "temp null -> null");
echo "== linear_trend ==\n";
$t = linear_trend([[0, 0], [1, 2], [2, 4]]);
ok($t !== null && approx($t['slope'], 2.0, 1e-6) && approx($t['r2'], 1.0, 1e-6), "완전선형 slope=2 r2=1");
ok(linear_trend([[1, 5], [1, 7]]) === null, "동일 x -> null");
ok(linear_trend([[0, 1]]) === null, "점<2 -> null");
$t2 = linear_trend([[0, 5], [1, 5], [2, 5]]);
ok($t2 !== null && approx($t2['slope'], 0.0, 1e-6), "평탄 slope~0");
echo "== threshold_eta_min ==\n";
ok(approx(threshold_eta_min(20.0, 27.0, 0.1, 180.0), 70.0, 0.5), "20->27 @0.1/min = 70분");
ok(threshold_eta_min(20.0, 27.0, -0.1, 180.0) === null, "멀어지는 방향 -> null");
ok(threshold_eta_min(20.0, 27.0, 0.01, 180.0) === null, "지평(700분>180) 밖 -> null");
ok(threshold_eta_min(20.0, 27.0, 0.0, 180.0) === null, "정체 slope0 -> null");
echo "== headroom_state ==\n";
ok(headroom_state(5.0, 2.0) === 'safe', "여유 5 warn2 -> safe");
ok(headroom_state(1.0, 2.0) === 'near', "여유 1 warn2 -> near");
ok(headroom_state(-1.0, 2.0) === 'over', "여유 -1 -> over");
echo "== forecast_metric ==\n";
// 상승 온도 시리즈: 24.0 -> 26.5 (6점, 5분 간격) -> slope ~ 0.1/min (완전선형)
$recent = [];
for ($i = 0; $i < 6; $i++) {
$recent[] = ['t' => 1751000000 + $i * 300, 'temp' => 24.0 + $i * 0.5, 'hum' => 55.0];
}
$cfg = ['temp_high' => 30.0, 'temp_low' => 10.0, 'rh_high' => 70.0, 'rh_low' => 20.0,
'dew_margin' => 3.0, 'warn_temp' => 3.0, 'warn_rh' => 8.0,
'horizon_min' => 180.0, 'min_points' => 6, 'min_r2' => 0.5, 'rate_temp_10' => 3.0, 'rate_rh_10' => 10.0];
$fc = forecast_metric(26.5, 55.0, $recent, $cfg);
$hh = null;
foreach ($fc['headroom'] as $h) { if ($h['key'] === 'high_temp') $hh = $h; }
ok($hh !== null && approx($hh['margin'], 3.5, 0.01), "고온 여유 = 30-26.5 = 3.5");
ok($hh !== null && $hh['state'] === 'safe', "여유 3.5 > warn 3.0 -> safe");
ok($fc['trend']['temp'] !== null && $fc['trend']['temp']['dir'] === 'up', "온도 추세 상승");
ok($fc['trend']['temp']['eta'] !== null && $fc['trend']['temp']['eta']['key'] === 'high_temp', "고온 ETA 산출(추세로 도달 예상)");
ok($fc['dew'] !== null && $fc['dew']['enabled'] === true, "이슬점 마진 계산(활성)");
// 저온 임계 비활성(null) -> low_temp headroom 없음
$fc2 = forecast_metric(15.0, 50.0, [], ['temp_high' => 30.0, 'temp_low' => null, 'rh_high' => 70.0, 'rh_low' => null]);
$hasLow = false;
foreach ($fc2['headroom'] as $h) { if ($h['key'] === 'low_temp') $hasLow = true; }
ok(!$hasLow, "저온 임계 null -> low_temp 여유 스킵");
// 누수전용(메트릭 없음) -> headroom 비고, dew null
$fc3 = forecast_metric(null, null, [], $cfg);
ok(count($fc3['headroom']) === 0 && $fc3['dew'] === null, "온습도 null -> 메트릭 신호 스킵");
echo "== forecast_freshness ==\n";
$now = 1751000000;
ok(forecast_freshness($now - 100, $now, 1200)['offline'] === false, "100초 경과 -> 온라인");
ok(forecast_freshness($now - 1300, $now, 1200)['offline'] === true, "1300>1200 -> 오프라인");
$fr = forecast_freshness($now - 700, $now, 1200, 0.5);
ok($fr['imminent'] === true && $fr['offline'] === false, "700>=600(50%) 아직<1200 -> 임박");
ok(forecast_freshness(null, $now, 1200)['offline'] === false, "last null -> offline false");
echo "== risk_items ==\n";
$leakRisks = risk_items(['sensor_id' => 3, 'name' => '지하배관', 'is_leak' => 1], ['headroom' => []], ['offline' => false]);
ok(count($leakRisks) === 1 && $leakRisks[0]['condition'] === 'leak' && $leakRisks[0]['severity'] === 'critical', "누수 -> leak critical");
$offRisks = risk_items(['sensor_id' => 1, 'name' => 'A'], ['headroom' => []], ['offline' => true, 'elapsed_min' => 25]);
ok(count($offRisks) === 1 && $offRisks[0]['condition'] === 'offline' && $offRisks[0]['severity'] === 'critical', "오프라인 -> offline critical");
$overRisks = risk_items(['sensor_id' => 2, 'name' => 'B'], ['headroom' => [['key' => 'high_temp', 'label' => '고온', 'margin' => -1.2, 'unit' => '℃', 'state' => 'over']], 'trend' => []], ['offline' => false]);
ok(count($overRisks) === 1 && $overRisks[0]['severity'] === 'critical', "고온 초과 -> critical");
$nearRisks = risk_items(['sensor_id' => 2, 'name' => 'B'],
['headroom' => [['key' => 'high_temp', 'label' => '고온', 'margin' => 1.5, 'unit' => '℃', 'state' => 'near']],
'trend' => ['temp' => ['eta' => ['key' => 'high_temp', 'min' => 40]], 'hum' => null]], ['offline' => false]);
ok(count($nearRisks) === 1 && $nearRisks[0]['severity'] === 'warn' && $nearRisks[0]['eta_min'] === 40, "고온 근접 -> warn + ETA 40");
echo "\n==== forecast_test: $tests 검사, $fails 실패 ====\n";
exit($fails === 0 ? 0 : 1);