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; }