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), 헤드리스 렌더 확인.
This commit is contained in:
parent
e0f0f0963a
commit
031a0c0fdf
7 changed files with 585 additions and 0 deletions
|
|
@ -321,6 +321,45 @@ tbody tr:hover td { background: var(--surface-2); }
|
|||
|
||||
.refresh { float: right; font-size: .8125rem; color: var(--ink-2); }
|
||||
|
||||
/* ── 예보(카드 상시 예방 신호) ───────────────────────────────────────────── */
|
||||
.forecast { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-2); }
|
||||
.forecast-chip {
|
||||
display: inline-flex; align-items: center; gap: .35em; border-radius: var(--r-pill);
|
||||
padding: 2px 9px; font-size: .75rem; font-weight: 700; line-height: 1.5;
|
||||
background: var(--warn-bg); color: var(--warn-ink);
|
||||
}
|
||||
.forecast-chip::before { content: ""; width: .45em; height: .45em; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.forecast-chip.near { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.forecast-chip.over { background: var(--leak-bg); color: var(--leak-ink); }
|
||||
.forecast-note { font-size: .75rem; color: var(--ink-3); }
|
||||
|
||||
/* ── 대응 필요 섹션(위험 시에만 노출: 조건 + 플레이북 + 담당자) ─────────────── */
|
||||
.response-panel {
|
||||
background: var(--surface); border: 1px solid var(--line); border-left: 6px solid var(--leak);
|
||||
border-radius: var(--r); padding: var(--sp-5); margin-bottom: var(--sp-5);
|
||||
}
|
||||
.response-panel h2 { margin: 0 0 var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.response-count {
|
||||
font-size: .8125rem; font-weight: 700; color: var(--leak-ink);
|
||||
background: var(--leak-bg); border-radius: var(--r-pill); padding: 2px 10px;
|
||||
}
|
||||
.response-item { border-top: 1px solid var(--line-2); padding: var(--sp-3) 0; }
|
||||
.response-item:first-of-type { border-top: none; padding-top: 0; }
|
||||
.response-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-2); }
|
||||
.response-head strong { font-size: .95rem; }
|
||||
.response-detail { color: var(--ink-2); font-size: .875rem; }
|
||||
.playbook { margin-top: var(--sp-2); background: var(--surface-2); border-radius: var(--r-sm); padding: var(--sp-3) var(--sp-4); }
|
||||
.playbook-title { font-size: .8125rem; font-weight: 700; color: var(--ink-2); margin-bottom: 4px; }
|
||||
.playbook-steps { margin: 0; padding-left: 1.4em; }
|
||||
.playbook-steps li { font-size: .875rem; line-height: 1.7; }
|
||||
.response-contacts {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3);
|
||||
margin-top: var(--sp-4); padding-top: var(--sp-3); border-top: 1px solid var(--line-2);
|
||||
}
|
||||
.contacts-label { font-size: .8125rem; font-weight: 700; color: var(--ink-2); }
|
||||
.contact { font-size: .875rem; }
|
||||
.contact a { font-weight: 700; }
|
||||
|
||||
/* ── 폼 / 로그인 / 카드형 페이지 ────────────────────────────────────────── */
|
||||
.login-box {
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,17 @@ return [
|
|||
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로(이슬점) 경보 비활성(0 이면 판정 분기 스킵)
|
||||
'METRIC_ALERT_COOLDOWN_SEC' => 1800, // 동일 종류 경보 재발송 억제(초, 기본 30분)
|
||||
|
||||
// 예보·대응(대시보드 예방 신호 + 조치 가이드). 전부 기본값으로 동작 — 필요 시만 override.
|
||||
// FORECAST_ENABLED(true), FORECAST_WARN_MARGIN_TEMP_C(2.0), FORECAST_WARN_MARGIN_RH(8.0),
|
||||
// FORECAST_HORIZON_MIN(180), FORECAST_RATE_TEMP_C_PER_10MIN(3.0) 등. config.php 참고.
|
||||
// 대응 담당자(대시보드 '대응 필요' 섹션에 표시). 비우면 담당자 블록 생략.
|
||||
'RESPONSE_CONTACTS' => [
|
||||
// ['role' => '시설담당', 'name' => '홍길동', 'phone' => '010-0000-0000'],
|
||||
// ['role' => '야간당직', 'name' => '김철수', 'phone' => '010-0000-0000'],
|
||||
],
|
||||
// 상태별 대응 체크리스트는 config.php 의 기본 플레이북을 사용. 현장 절차로 바꾸려면
|
||||
// 'RESPONSE_PLAYBOOKS' => ['high_temp' => ['title'=>'고온 대응','steps'=>['...']], ...] 로 통째 override.
|
||||
|
||||
// 미복귀 경보 2차(야간) 에스컬레이션 수신자. 비우면 비활성(opt-in).
|
||||
'SMS_RECIPIENTS_TIER2' => [],
|
||||
'ESCALATE_AFTER_SEC' => 1800, // 경보 미복귀 30분 후 2차 통지
|
||||
|
|
|
|||
|
|
@ -152,6 +152,37 @@ define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800));
|
|||
define('METRIC_DEWPOINT_MARGIN_C', (float)cfg('METRIC_DEWPOINT_MARGIN_C', 3.0));
|
||||
define('METRIC_DEWPOINT_HYSTERESIS_C', (float)cfg('METRIC_DEWPOINT_HYSTERESIS_C', 1.0));
|
||||
|
||||
// ─── 예보·대응 (대시보드 예방 신호 + 조치 가이드) ─────────────────────────────
|
||||
// 기존 측정 데이터만으로 "경보가 터지기 전"을 대시보드에 표시한다(php/forecast.php).
|
||||
// 임계 여유·추세 ETA·이슬점 마진·변화율을 계산하고, 위험 시 상태별 대응 체크리스트를
|
||||
// 보여준다. DB 스키마 변경 없음(전부 기존 leak_sensor_metric/status 로 산출).
|
||||
define('FORECAST_ENABLED', (bool)cfg('FORECAST_ENABLED', true));
|
||||
define('FORECAST_RECENT_POINTS', (int)cfg('FORECAST_RECENT_POINTS', 12)); // 회귀에 쓸 최근 측정 수(≈1시간)
|
||||
define('FORECAST_MIN_POINTS', (int)cfg('FORECAST_MIN_POINTS', 6)); // ETA 산출 최소 점수
|
||||
define('FORECAST_MIN_R2', (float)cfg('FORECAST_MIN_R2', 0.5)); // 추세 신뢰(결정계수) 하한
|
||||
define('FORECAST_HORIZON_MIN', (int)cfg('FORECAST_HORIZON_MIN', 180)); // ETA 표시 지평(분)
|
||||
define('FORECAST_WARN_MARGIN_TEMP_C', (float)cfg('FORECAST_WARN_MARGIN_TEMP_C', 2.0)); // 온도 여유 근접 기준(℃)
|
||||
define('FORECAST_WARN_MARGIN_RH', (float)cfg('FORECAST_WARN_MARGIN_RH', 8.0)); // 습도 여유 근접 기준(%)
|
||||
define('FORECAST_RATE_TEMP_C_PER_10MIN', (float)cfg('FORECAST_RATE_TEMP_C_PER_10MIN', 3.0)); // 급변 기준(℃/10분)
|
||||
define('FORECAST_RATE_RH_PER_10MIN', (float)cfg('FORECAST_RATE_RH_PER_10MIN', 10.0)); // 급변 기준(%/10분)
|
||||
|
||||
// 상태별 대응 체크리스트(플레이북). 조건 키 → ['title'=>, 'steps'=>[...]].
|
||||
// config.local.php 에서 'RESPONSE_PLAYBOOKS' 로 통째 오버라이드 가능(현장 절차 반영).
|
||||
$default_playbooks = [
|
||||
'leak' => ['title' => '누수 대응', 'steps' => ['현장 즉시 확인', '해당 구역 급수 밸브 차단', '누수 지점·범위 사진 확인', '전기 설비 침수 여부 안전 점검', '조치 후 복구 확인']],
|
||||
'high_temp' => ['title' => '고온 대응', 'steps' => ['냉방·환기 동작 확인', '실외기/흡배기·필터 막힘 점검', '발열 장비·부하 확인', '필요 시 임시 냉각']],
|
||||
'low_temp' => ['title' => '저온 대응', 'steps' => ['난방 동작 확인', '외기 유입·틈새 점검', '동결 위험 배관 확인']],
|
||||
'high_humidity' => ['title' => '고습 대응', 'steps' => ['제습기 동작 확인', '누수·침수 여부 확인', '환기 상태 점검']],
|
||||
'low_humidity' => ['title' => '저습 대응', 'steps' => ['가습 설비 확인', '정전기 민감 장비 점검']],
|
||||
'condensation' => ['title' => '결로 위험 대응', 'steps' => ['차가운 표면·배관 결로 확인', '제습·환기로 이슬점 마진 확보', '장비 표면 물기 점검']],
|
||||
'offline' => ['title' => '오프라인 대응', 'steps' => ['장비 전원·상태 표시등 확인', '유선/무선 네트워크 연결 확인', 'NTP 시각 동기 확인', '필요 시 장비 재부팅', '복구 후 수신 재개 확인']],
|
||||
];
|
||||
define('RESPONSE_PLAYBOOKS', cfg('RESPONSE_PLAYBOOKS', $default_playbooks));
|
||||
|
||||
// 대응 담당자 연락처: [['role'=>'시설담당','name'=>'홍길동','phone'=>'010-...'], ...].
|
||||
// 비면(기본) 대응 섹션에서 연락처 블록을 생략한다. config.local.php 에서 채운다.
|
||||
define('RESPONSE_CONTACTS', cfg('RESPONSE_CONTACTS', []));
|
||||
|
||||
// ─── SMS 잔액(잔여 건수) 사전경보 ─────────────────────────────────────────────
|
||||
// Cafe24 응답의 잔여 건수가 이 값 이하로 내려가면 1회 '[SMS 잔액부족]' 경고를 보낸다
|
||||
// (경보 채널이 잔액 소진으로 조용히 죽는 것을 사전 방지). 쿨다운으로 반복을 억제.
|
||||
|
|
|
|||
|
|
@ -314,6 +314,70 @@ if ($alert_count > 0) {
|
|||
$system_title = '정상 감시 중';
|
||||
$system_detail = '현재 온습도 경보와 중요 운영 경고가 없습니다.';
|
||||
}
|
||||
|
||||
// ── 예보·대응 신호 (forecast.php — 순수계산) ──────────────────────────────
|
||||
// 기존 측정 데이터만으로 임계 여유·추세 ETA·이슬점·변화율·신선도를 산출한다.
|
||||
// $statuses 는 모든 센서(누수 노드 포함)를 담으므로 한 번만 순회한다(중복 없음).
|
||||
require_once __DIR__ . '/forecast.php';
|
||||
$forecasts = []; // sensor_id => forecast_metric() 결과 (카드 예보줄에 사용)
|
||||
$risk_items_all = []; // 대응 섹션용 정규화 위험 목록
|
||||
if (FORECAST_ENABLED) {
|
||||
$fc_cfg = [
|
||||
'temp_high' => METRIC_TEMP_HIGH_C,
|
||||
'temp_low' => METRIC_TEMP_LOW_C,
|
||||
'rh_high' => METRIC_RH_HIGH,
|
||||
'rh_low' => (METRIC_RH_LOW >= 0 ? METRIC_RH_LOW : null), // -1 = 비활성
|
||||
'dew_margin'=> METRIC_DEWPOINT_MARGIN_C, // 0 이하 = 결로 판정 스킵
|
||||
'warn_temp' => FORECAST_WARN_MARGIN_TEMP_C,
|
||||
'warn_rh' => FORECAST_WARN_MARGIN_RH,
|
||||
'horizon_min'=> FORECAST_HORIZON_MIN,
|
||||
'min_points'=> FORECAST_MIN_POINTS,
|
||||
'min_r2' => FORECAST_MIN_R2,
|
||||
'rate_temp_10'=> FORECAST_RATE_TEMP_C_PER_10MIN,
|
||||
'rate_rh_10' => FORECAST_RATE_RH_PER_10MIN,
|
||||
];
|
||||
$now_ts = time();
|
||||
$recent_stmt = $db->prepare(
|
||||
"SELECT temperature_c, humidity_percent, UNIX_TIMESTAMP(measured_at) AS t
|
||||
FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY id DESC LIMIT :lim"
|
||||
);
|
||||
foreach ($statuses as $s) {
|
||||
$sid = (int)$s['sensor_id'];
|
||||
$metric = $s['latest_metric'] ?? null;
|
||||
$temp = ($metric && $metric['temperature_c'] !== null) ? (float)$metric['temperature_c'] : null;
|
||||
$hum = ($metric && $metric['humidity_percent'] !== null) ? (float)$metric['humidity_percent'] : null;
|
||||
|
||||
$recent = [];
|
||||
if ($temp !== null || $hum !== null) {
|
||||
$recent_stmt->bindValue(':sid', $sid, PDO::PARAM_INT);
|
||||
$recent_stmt->bindValue(':lim', (int)FORECAST_RECENT_POINTS, PDO::PARAM_INT);
|
||||
$recent_stmt->execute();
|
||||
foreach (array_reverse($recent_stmt->fetchAll()) as $rr) { // 오래된→최신
|
||||
$recent[] = [
|
||||
't' => (int)$rr['t'],
|
||||
'temp' => $rr['temperature_c'] !== null ? (float)$rr['temperature_c'] : null,
|
||||
'hum' => $rr['humidity_percent'] !== null ? (float)$rr['humidity_percent'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$fc = forecast_metric($temp, $hum, $recent, $fc_cfg);
|
||||
$forecasts[$sid] = $fc;
|
||||
|
||||
// 신선도 — offline 은 대시보드 기존 판정($s['is_offline'])을 단일 진실원으로 사용
|
||||
$fr = forecast_freshness(strtotime($s['last_seen'] ?? '') ?: null, $now_ts, HEARTBEAT_TIMEOUT_SEC);
|
||||
$fr['offline'] = (bool)$s['is_offline'];
|
||||
if ($fr['offline']) { $fr['imminent'] = false; }
|
||||
|
||||
$risk_items_all = array_merge($risk_items_all, risk_items(
|
||||
['sensor_id' => $sid, 'name' => $s['sensor_name'], 'location' => $s['device_location'],
|
||||
'is_leak' => (int)($s['is_leak'] ?? 0)],
|
||||
$fc, $fr
|
||||
));
|
||||
}
|
||||
// critical 먼저, 그다음 warn
|
||||
usort($risk_items_all, fn($a, $b) => ($a['severity'] === 'critical' ? 0 : 1) <=> ($b['severity'] === 'critical' ? 0 : 1));
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -417,6 +481,44 @@ if ($alert_count > 0) {
|
|||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (FORECAST_ENABLED && !empty($risk_items_all)): ?>
|
||||
<section class="response-panel" aria-label="대응 필요 항목">
|
||||
<h2>대응 필요 <span class="response-count"><?= count($risk_items_all) ?>건</span></h2>
|
||||
<?php foreach ($risk_items_all as $risk):
|
||||
$pb = RESPONSE_PLAYBOOKS[$risk['condition']] ?? null;
|
||||
$sev_pill = $risk['severity'] === 'critical' ? 'alert' : 'offline';
|
||||
$sev_text = $risk['severity'] === 'critical' ? '경보' : '주의';
|
||||
?>
|
||||
<div class="response-item <?= htmlspecialchars($risk['severity']) ?>">
|
||||
<div class="response-head">
|
||||
<span class="pill <?= $sev_pill ?>"><?= $sev_text ?></span>
|
||||
<strong><?= htmlspecialchars($risk['name']) ?><?= $risk['location'] ? ' · ' . htmlspecialchars($risk['location']) : '' ?></strong>
|
||||
<span class="response-detail"><?= htmlspecialchars($risk['detail']) ?></span>
|
||||
</div>
|
||||
<?php if ($pb && !empty($pb['steps'])): ?>
|
||||
<div class="playbook">
|
||||
<div class="playbook-title"><?= htmlspecialchars($pb['title'] ?? '권장 조치') ?> — 권장 조치</div>
|
||||
<ol class="playbook-steps">
|
||||
<?php foreach ($pb['steps'] as $step): ?><li><?= htmlspecialchars($step) ?></li><?php endforeach; ?>
|
||||
</ol>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!empty(RESPONSE_CONTACTS)): ?>
|
||||
<div class="response-contacts">
|
||||
<span class="contacts-label">담당자</span>
|
||||
<?php foreach (RESPONSE_CONTACTS as $ct):
|
||||
$ph = preg_replace('/[^0-9+]/', '', (string)($ct['phone'] ?? '')); ?>
|
||||
<span class="contact">
|
||||
<?= htmlspecialchars(trim(($ct['role'] ?? '') . ' ' . ($ct['name'] ?? ''))) ?><?php if ($ph !== ''): ?> <a href="tel:<?= htmlspecialchars($ph) ?>"><?= htmlspecialchars((string)$ct['phone']) ?></a><?php endif; ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="mobile-quick" aria-label="모바일 빠른 조회">
|
||||
<div class="quick-grid">
|
||||
<a class="quick-tile <?= $alert_count > 0 ? 'danger' : 'ok' ?>" href="#sensors-section">
|
||||
|
|
@ -564,6 +666,35 @@ if ($alert_count > 0) {
|
|||
(<?= $s['offline_elapsed_min'] ?>분 전)
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
// 카드 예보줄(상시): 온라인 + 예보 있으면 near/over 여유·ETA·결로·급변만 노출(혼잡 방지)
|
||||
$sid_fc = (int)$s['sensor_id'];
|
||||
$fc = (!$s['is_offline'] && isset($forecasts[$sid_fc])) ? $forecasts[$sid_fc] : null;
|
||||
if ($fc):
|
||||
$chips = [];
|
||||
foreach ($fc['headroom'] as $h) {
|
||||
if ($h['state'] === 'safe') { continue; } // 근접/초과만 강조
|
||||
$chips[] = ['state' => $h['state'], 'text' => $h['label'] . ' 여유 ' . $h['margin'] . $h['unit']];
|
||||
}
|
||||
foreach (['temp', 'hum'] as $vk) {
|
||||
$e = $fc['trend'][$vk]['eta'] ?? null;
|
||||
if ($e) { $chips[] = ['state' => 'near', 'text' => '↗ ~' . (int)$e['min'] . '분 뒤 ' . metric_status_label($e['key'])]; }
|
||||
}
|
||||
foreach ($fc['rate'] as $r) {
|
||||
$chips[] = ['state' => 'near', 'text' => ($r['spike'] === 'rise' ? '급상승 ' : '급락 ') . ($r['per10'] > 0 ? '+' : '') . $r['per10'] . '/10분'];
|
||||
}
|
||||
if (!empty($fc['dew']['risk'])) {
|
||||
$chips[] = ['state' => 'near', 'text' => '결로주의 (마진 ' . $fc['dew']['margin'] . '℃)'];
|
||||
}
|
||||
?>
|
||||
<div class="forecast">
|
||||
<?php if ($chips): foreach ($chips as $c): ?>
|
||||
<span class="forecast-chip <?= $c['state'] ?>"><?= htmlspecialchars($c['text']) ?></span>
|
||||
<?php endforeach; else: ?>
|
||||
<span class="forecast-note">예보: 임계 여유 충분</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($statuses)): ?>
|
||||
|
|
|
|||
272
php/forecast.php
Normal file
272
php/forecast.php
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ run_php tests/raw_body_signature_test.php
|
|||
run_php tests/security_hardening_test.php
|
||||
run_php tests/threshold_sms_test.php
|
||||
run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴
|
||||
run_php tests/forecast_test.php # 예보(이슬점/회귀/ETA/여유/신선도/위험) 순수함수
|
||||
|
||||
echo ""
|
||||
echo "=== 2) 펌웨어 호스트 패리티 (C↔Python↔PHP) ==="
|
||||
|
|
|
|||
100
tests/forecast_test.php
Normal file
100
tests/forecast_test.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?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);
|
||||
Loading…
Reference in a new issue