POSA_LEAKSMS/php/forecast.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

272 lines
12 KiB
PHP

<?php
// =============================================================================
// forecast.php — 예방·예측 신호 계산 (순수 함수: DB·출력·전역상태 없음)
//
// 대시보드가 기존 측정 데이터만으로 "경보가 터지기 전"을 보여주기 위한 계산부.
// - 임계 여유(headroom): 현재값과 임계 사이 거리 + 3단계(safe/near/over)
// - 추세 ETA: 최근점 선형회귀로 임계 도달 예상 시간(분)
// - 이슬점 결로 마진: Magnus 식 Td → margin = T - Td
// - 변화율 급변: 단기 기울기로 급상승/급락 플래그
// - 신선도: last_seen 경과 → 오프라인 임박
// - 위험 항목(risk items): 대응 섹션에 뿌릴 정규화된 목록
//
// 모든 함수는 인자로 데이터·임계·튜닝을 받는다(상수 참조 없음 → 단위 테스트 용이).
// 임계 비활성(<=0 또는 명시 무효값)은 호출부가 걸러 넘긴다(config 규칙 존중).
// =============================================================================
/**
* 이슬점(℃) — Magnus-Tetens. rh 는 0<rh<=100.
* 유효하지 않으면 null.
*/
function dew_point_c(?float $t, ?float $rh): ?float
{
if ($t === null || $rh === null || $rh <= 0.0 || $rh > 100.0) {
return null;
}
$a = 17.62;
$b = 243.12;
$gamma = log($rh / 100.0) + ($a * $t) / ($b + $t);
if ($a - $gamma == 0.0) {
return null;
}
return ($b * $gamma) / ($a - $gamma);
}
/**
* 최소자승 선형회귀. points = [[x, y], ...] (x·y float).
* 반환 ['slope'=>Δy/Δx, 'intercept'=>, 'r2'=>0..1, 'n'=>] 또는 null(점<2, x 분산 0).
*/
function linear_trend(array $points): ?array
{
$n = count($points);
if ($n < 2) {
return null;
}
$sx = $sy = $sxx = $sxy = $syy = 0.0;
foreach ($points as $p) {
$x = (float)$p[0];
$y = (float)$p[1];
$sx += $x; $sy += $y; $sxx += $x * $x; $sxy += $x * $y; $syy += $y * $y;
}
$denom = $n * $sxx - $sx * $sx;
if (abs($denom) < 1e-9) {
return null; // x 분산 0 (모두 같은 시각) → 기울기 미정
}
$slope = ($n * $sxy - $sx * $sy) / $denom;
$intercept = ($sy - $slope * $sx) / $n;
$ss_tot = $syy - ($sy * $sy) / $n;
$ss_res = 0.0;
foreach ($points as $p) {
$e = (float)$p[1] - ($intercept + $slope * (float)$p[0]);
$ss_res += $e * $e;
}
$r2 = $ss_tot > 1e-12 ? max(0.0, 1.0 - $ss_res / $ss_tot) : 1.0;
return ['slope' => $slope, 'intercept' => $intercept, 'r2' => $r2, 'n' => $n];
}
/**
* 임계 도달 ETA(분). cur 현재값, thr 임계, slope_per_min 분당 변화, horizon_min 지평.
* 임계 방향으로 수렴 중이고 지평 내일 때만 양수 분, 아니면 null.
*/
function threshold_eta_min(float $cur, float $thr, float $slope_per_min, float $horizon_min): ?float
{
if (abs($slope_per_min) < 1e-6) {
return null; // 정체 → 도달 안 함
}
$eta = ($thr - $cur) / $slope_per_min;
if ($eta <= 0.0 || $eta > $horizon_min) {
return null; // 이미 넘음/멀어짐 or 지평 밖
}
return $eta;
}
/**
* 여유 상태 3단계. margin = 현재값과 임계 거리(임계 안 넘었으면 양수).
* over: 이미 임계 초과, near: warn_margin 이내 근접, safe: 여유.
*/
function headroom_state(float $margin, float $warn_margin): string
{
if ($margin <= 0.0) {
return 'over';
}
if ($margin <= $warn_margin) {
return 'near';
}
return 'safe';
}
/**
* points 를 [분(minutes-from-first), value] 로 변환(회귀 x축). null 값은 제외.
* recent = [['t'=>epoch_sec, vkey=>?float], ...] 오래된→최신.
*/
function _series_minutes(array $recent, string $vkey): array
{
$pts = [];
$t0 = null;
foreach ($recent as $r) {
$v = $r[$vkey] ?? null;
if ($v === null) {
continue;
}
$t = (int)($r['t'] ?? 0);
if ($t0 === null) {
$t0 = $t;
}
$pts[] = [($t - $t0) / 60.0, (float)$v];
}
return $pts;
}
/**
* 한 온습도 센서의 예보 계산.
* $temp/$hum: 현재값(없으면 null). $recent: [['t'=>epoch,'temp'=>,'hum'=>],...] 오래된→최신.
* $c: 튜닝/임계 배열 — temp_high, temp_low, rh_high, rh_low(각 null=비활성),
* dew_margin(>0 시 결로 판정), warn_temp, warn_rh, horizon_min, min_points, min_r2,
* rate_temp_10, rate_rh_10.
* 반환: ['headroom'=>[...], 'trend'=>[...], 'dew'=>..|null, 'rate'=>..].
*/
function forecast_metric(?float $temp, ?float $hum, array $recent, array $c): array
{
$out = ['headroom' => [], 'trend' => ['temp' => null, 'hum' => null], 'dew' => null, 'rate' => []];
// ── 임계 여유 ──────────────────────────────────────────────
$band = [
['key' => 'high_temp', 'label' => '고온', 'cur' => $temp, 'thr' => $c['temp_high'] ?? null, 'unit' => '℃', 'above' => true, 'warn' => $c['warn_temp'] ?? 2.0],
['key' => 'low_temp', 'label' => '저온', 'cur' => $temp, 'thr' => $c['temp_low'] ?? null, 'unit' => '℃', 'above' => false, 'warn' => $c['warn_temp'] ?? 2.0],
['key' => 'high_humidity', 'label' => '고습', 'cur' => $hum, 'thr' => $c['rh_high'] ?? null, 'unit' => '%', 'above' => true, 'warn' => $c['warn_rh'] ?? 8.0],
['key' => 'low_humidity', 'label' => '저습', 'cur' => $hum, 'thr' => $c['rh_low'] ?? null, 'unit' => '%', 'above' => false, 'warn' => $c['warn_rh'] ?? 8.0],
];
foreach ($band as $b) {
if ($b['cur'] === null || $b['thr'] === null) {
continue; // 값 없거나 임계 비활성
}
// above=고온/고습: margin = thr - cur (양수면 아직 여유). below=저온/저습: margin = cur - thr.
$margin = $b['above'] ? ($b['thr'] - $b['cur']) : ($b['cur'] - $b['thr']);
$out['headroom'][] = [
'key' => $b['key'], 'label' => $b['label'], 'thr' => $b['thr'], 'unit' => $b['unit'],
'margin' => round($margin, 1), 'state' => headroom_state($margin, (float)$b['warn']),
];
}
// ── 추세 ETA + 변화율 ──────────────────────────────────────
$minPts = (int)($c['min_points'] ?? 6);
$minR2 = (float)($c['min_r2'] ?? 0.5);
$horizon = (float)($c['horizon_min'] ?? 180.0);
foreach (['temp' => [$temp, $c['temp_high'] ?? null, $c['temp_low'] ?? null],
'hum' => [$hum, $c['rh_high'] ?? null, $c['rh_low'] ?? null]] as $vk => $vv) {
[$cur, $thrHigh, $thrLow] = $vv;
if ($cur === null) {
continue;
}
$series = _series_minutes($recent, $vk);
if (count($series) < $minPts) {
continue;
}
$tr = linear_trend($series);
if ($tr === null) {
continue;
}
$slope = $tr['slope']; // 분당
$dir = abs($slope) < 1e-4 ? 'flat' : ($slope > 0 ? 'up' : 'down');
$eta = null;
if ($tr['r2'] >= $minR2) {
if ($slope > 0 && $thrHigh !== null) {
$m = threshold_eta_min($cur, (float)$thrHigh, $slope, $horizon);
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'high_temp' : 'high_humidity', 'min' => (int)round($m)]; }
} elseif ($slope < 0 && $thrLow !== null) {
$m = threshold_eta_min($cur, (float)$thrLow, $slope, $horizon);
if ($m !== null) { $eta = ['key' => $vk === 'temp' ? 'low_temp' : 'low_humidity', 'min' => (int)round($m)]; }
}
}
$out['trend'][$vk] = ['slope_min' => round($slope, 3), 'dir' => $dir, 'r2' => round($tr['r2'], 2), 'eta' => $eta];
// ── 변화율 급변(최근 ~10분 환산) ──
$rateKey = $vk === 'temp' ? 'rate_temp_10' : 'rate_rh_10';
$rateThr = (float)($c[$rateKey] ?? ($vk === 'temp' ? 3.0 : 10.0));
$per10 = $slope * 10.0;
if (abs($per10) >= $rateThr) {
$out['rate'][$vk] = ['per10' => round($per10, 1), 'spike' => $slope > 0 ? 'rise' : 'drop'];
}
}
// ── 이슬점 결로 마진 ───────────────────────────────────────
$td = dew_point_c($temp, $hum);
if ($td !== null && $temp !== null) {
$margin = $temp - $td;
$dewCfg = (float)($c['dew_margin'] ?? 0.0);
$out['dew'] = [
'td' => round($td, 1),
'margin' => round($margin, 1),
'risk' => ($dewCfg > 0.0 && $margin <= $dewCfg),
'enabled' => ($dewCfg > 0.0),
];
}
return $out;
}
/**
* 신선도/오프라인 임박. last_seen_ts=epoch(없으면 null), now=epoch, timeout=하트비트 초.
* warn_ratio: timeout 대비 이 비율 넘으면 임박(기본 0.5).
* 반환 ['elapsed_sec','elapsed_min','offline','imminent'].
*/
function forecast_freshness(?int $last_seen_ts, int $now, int $timeout, float $warn_ratio = 0.5): array
{
if ($last_seen_ts === null || $timeout <= 0) {
return ['elapsed_sec' => null, 'elapsed_min' => null, 'offline' => false, 'imminent' => false];
}
$elapsed = max(0, $now - $last_seen_ts);
return [
'elapsed_sec' => $elapsed,
'elapsed_min' => (int)round($elapsed / 60),
'offline' => $elapsed >= $timeout,
'imminent' => ($elapsed < $timeout) && ($elapsed >= $timeout * $warn_ratio),
];
}
/**
* 대응 섹션용 위험 항목 목록. sensor(sensor_id·name·location·is_leak) + forecast(metric) + freshness 를 받아
* [['condition'=>, 'severity'=>'critical|warn', 'sensor_id'=>, 'name'=>, 'location'=>, 'detail'=>, 'eta_min'=>?], ...] 정규화.
* 이미 발생한 것(critical) + 임박한 것(warn) 모두 포함. 대시보드가 이 목록으로 플레이북/담당자를 붙인다.
*/
function risk_items(array $sensor, array $forecast, array $freshness): array
{
$risks = [];
$meta = ['sensor_id' => $sensor['sensor_id'] ?? 0, 'name' => $sensor['name'] ?? '', 'location' => $sensor['location'] ?? ''];
// 오프라인(critical) / 임박(warn)
if (!empty($freshness['offline'])) {
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'critical', 'detail' => "마지막 수신 {$freshness['elapsed_min']}분 전", 'eta_min' => null];
} elseif (!empty($freshness['imminent'])) {
$risks[] = $meta + ['condition' => 'offline', 'severity' => 'warn', 'detail' => "수신 지연 {$freshness['elapsed_min']}분 — 오프라인 임박", 'eta_min' => null];
}
// 누수(critical)
if (!empty($sensor['is_leak'])) {
$risks[] = $meta + ['condition' => 'leak', 'severity' => 'critical', 'detail' => '누수 감지', 'eta_min' => null];
}
// 임계 초과(critical) / 근접(warn) — headroom 기반
foreach ($forecast['headroom'] ?? [] as $h) {
if ($h['state'] === 'over') {
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'critical', 'detail' => "{$h['label']} 임계 초과 (현재 여유 {$h['margin']}{$h['unit']})", 'eta_min' => null];
} elseif ($h['state'] === 'near') {
$eta = null;
foreach (['temp', 'hum'] as $vk) {
$e = $forecast['trend'][$vk]['eta'] ?? null;
if ($e && $e['key'] === $h['key']) { $eta = $e['min']; break; }
}
$detail = "{$h['label']} 임계 근접 (여유 {$h['margin']}{$h['unit']})" . ($eta !== null ? " · 현 추세 ~{$eta}분 뒤 도달" : '');
$risks[] = $meta + ['condition' => $h['key'], 'severity' => 'warn', 'detail' => $detail, 'eta_min' => $eta];
}
}
// 결로(warn) — config 활성 시
if (!empty($forecast['dew']['risk'])) {
$risks[] = $meta + ['condition' => 'condensation', 'severity' => 'warn', 'detail' => "결로 위험 (이슬점 마진 {$forecast['dew']['margin']}℃)", 'eta_min' => null];
}
return $risks;
}