From d8b8e1f381cb18c1ff197387d8d18b9dfaa3c386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 7 Aug 2026 07:35:30 +0900 Subject: [PATCH] feat(analytics): weekday-hour pattern, precursor-spike alert, period trends The accumulated data starts predicting instead of just recording: - Time pattern (weekday x hour density grid): WHEN the site is dangerous, on one card - shift-change, post-lunch, Thursday-afternoon hotspots become visible. Bucketing runs in PHP on the server-local clock (site timezone) so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME). - Precursor-spike alert: last-7-days count of precursor signals (near-miss, zone entries, occupancy violations - signals, not injuries) vs the prior 4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a banner above the KPIs: an alarm on the PRECURSOR of an accident, with a concrete suggestion (staffing/route/rest review). Below the sample floor it never fires - a ratio over 2 events is noise, not statistics. - Period-over-period trends on every KPI: reportSummary gained an optional upper bound so the identical aggregation runs over the preceding window. Worsening turns red, improving turns accent - except the near-miss count, which stays NEUTRAL: more near-misses may mean better detection, not more danger, and the report must not editorialize that. Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing, spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend lines, Thursday-afternoon hotspot readable in the grid). --- server/public/assets/app.css | 20 ++++ server/public/assets/report.js | 91 +++++++++++++++++-- server/src/Dashboard/ReportController.php | 78 +++++++++++++++- server/src/Repository/EventRepository.php | 9 +- .../src/Repository/Pdo/PdoEventRepository.php | 43 +++++++-- server/src/View/report.php | 5 + server/tests/ReportTest.php | 47 ++++++++++ server/tests/Support/InMemoryRepositories.php | 11 ++- 8 files changed, 285 insertions(+), 19 deletions(-) diff --git a/server/public/assets/app.css b/server/public/assets/app.css index 25344ce..b71a3a6 100644 --- a/server/public/assets/app.css +++ b/server/public/assets/app.css @@ -492,6 +492,26 @@ a.event-thumb-slot:hover { border-color:var(--accent); box-shadow:0 0 0 3px var( .report-foot { margin:1.1rem 0 0; font-size:.72rem; color:var(--ink-3); } +/* 전조 급증 경보 — 사고 전 신호에 거는 알람이므로 KPI보다 위. */ +.report-spike { + margin:0 0 .8rem; padding:.6rem .8rem; font-size:.82rem; color:var(--warn-ink); + background:var(--warn-bg); border:1px solid var(--warn-line); border-radius:8px; +} +.report-spike strong { margin-right:.15rem; } + +.kpi-trend { font-size:.7rem; color:var(--ink-3); font-family:var(--mono); } +.kpi-trend.is-worse { color:var(--crit-ink); } +.kpi-trend.is-better { color:var(--accent); } + +.report-card-wide { grid-column:1 / -1; } +.pattern { display:flex; flex-direction:column; gap:2px; overflow-x:auto; } +.pattern-row { display:flex; align-items:center; gap:2px; } +.pattern-day { flex:none; width:1.4rem; font-size:.68rem; color:var(--ink-3); font-family:var(--mono); } +.pattern-hour { flex:1 0 14px; font-size:.62rem; color:var(--ink-3); font-family:var(--mono); text-align:left; } +.pattern-cell { flex:1 0 14px; height:16px; border-radius:3px; background:#edf0f4; } +.pattern-cell.is-filled { background:var(--crit-ink); } +.pattern-head { margin-bottom:2px; } + @media (max-width:860px) { .report-kpis { grid-template-columns:repeat(2, 1fr); } .report-grid { grid-template-columns:1fr; } diff --git a/server/public/assets/report.js b/server/public/assets/report.js index ce3ae06..0d19e43 100644 --- a/server/public/assets/report.js +++ b/server/public/assets/report.js @@ -60,6 +60,21 @@ return t; } + // 전기간 대비 추이 한 줄. neutral: 니어미스처럼 증감의 좋고 나쁨을 단정할 수 없는 지표 + // (감지가 늘어난 것일 수도, 위험이 늘어난 것일 수도) — 방향만 보여주고 색은 칠하지 않는다. + function trendLine(tile, prevVal, curVal, fmt, neutral) { + if (prevVal === null || prevVal === undefined) return; + var line = el('span', 'kpi-trend'); + if (prevVal === curVal) { + line.textContent = '이전 기간과 동일'; + } else { + var up = curVal > prevVal; + line.textContent = (up ? '▲ ' : '▼ ') + '이전 기간 ' + fmt(prevVal) + ' → ' + fmt(curVal); + if (!neutral) line.className = 'kpi-trend ' + (up ? 'is-worse' : 'is-better'); + } + tile.appendChild(line); + } + function barRow(label, value, max, text, hot) { var row = el('div', 'bar-row'); var head = el('div', 'bar-head'); @@ -82,18 +97,44 @@ + new Date((r.generated_at - r.days * 86400) * 1000).toLocaleDateString('ko-KR') + ' ~ ' + new Date(r.generated_at * 1000).toLocaleDateString('ko-KR'); + // 전조 급증 경보 — 사고가 아니라 사고의 전조에 거는 알람이라 리포트 최상단에 선다. + var alertEl = document.getElementById('report-alert'); + if (alertEl) { + var sp = r.precursor_alert || {}; + alertEl.hidden = !sp.alert; + alertEl.textContent = ''; + if (sp.alert) { + var lead = el('strong', null, '⚠ 전조 급증'); + alertEl.appendChild(lead); + alertEl.appendChild(document.createTextNode( + ' — 최근 7일 니어미스·구역 신호 ' + sp.this_week + '건, 이전 4주 주평균 ' + + sp.baseline_weekly_avg + '건' + (sp.ratio ? ' (' + sp.ratio + '배)' : '') + + '. 배치·동선·휴식 등 사전 개입을 검토하세요.')); + } + } + root.textContent = ''; - root.appendChild(kpi('중대 이벤트', String(severe), - '위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : '')); - root.appendChild(kpi('평균 대응 시간', fmtDuration(r.tta.avg_s), - r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음')); + var prev = r.previous || {}; + var kSevere = kpi('중대 이벤트', String(severe), + '위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : ''); + trendLine(kSevere, (prev.critical || 0) + (prev.high || 0), severe, String); + root.appendChild(kSevere); + var kTta = kpi('평균 대응 시간', fmtDuration(r.tta.avg_s), + r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음'); + if (r.tta.avg_s !== null) trendLine(kTta, prev.tta_avg_s, r.tta.avg_s, fmtDuration); + root.appendChild(kTta); // 하인리히 지표: 니어미스가 사고보다 얼마나 앞서 잡히고 있나 — 예방이 작동한다는 증거. var nm = r.near_miss; - root.appendChild(kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents, - nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음')); + var kNm = kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents, + nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음'); + trendLine(kNm, prev.near_fall, nm.near_fall, String, true); + root.appendChild(kNm); var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0; - root.appendChild(kpi('오탐율', fpPct + '%', - '전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : '')); + var prevFpPct = prev.fp && prev.fp.total > 0 ? Math.round(prev.fp.tagged / prev.fp.total * 100) : null; + var kFp = kpi('오탐율', fpPct + '%', + '전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : ''); + trendLine(kFp, prevFpPct, fpPct, function (v) { return v + '%'; }); + root.appendChild(kFp); typesEl.textContent = ''; var typeKeys = Object.keys(r.by_type); @@ -142,6 +183,40 @@ }); rulesEl.appendChild(rules); + // 시간 패턴: 요일×시간 발생 밀도 — "언제가 위험한가"를 한 장에. + var patternEl = document.getElementById('report-pattern'); + if (patternEl) { + patternEl.textContent = ''; + var pt = r.pattern || { grid: [], max: 0 }; + if (!pt.max) { + patternEl.appendChild(el('p', 'report-empty', '기간 내 감지가 없습니다.')); + } else { + var DAYS = ['월', '화', '수', '목', '금', '토', '일']; + var table = el('div', 'pattern'); + var head = el('div', 'pattern-row pattern-head'); + head.appendChild(el('span', 'pattern-day', '')); + for (var h = 0; h < 24; h++) head.appendChild(el('span', 'pattern-hour', h % 6 === 0 ? String(h) : '')); + table.appendChild(head); + pt.grid.forEach(function (rowArr, d) { + var row = el('div', 'pattern-row'); + row.appendChild(el('span', 'pattern-day', DAYS[d])); + rowArr.forEach(function (n, h) { + var cell = el('span', 'pattern-cell'); + if (n > 0) { + cell.style.opacity = String(0.25 + 0.75 * (n / pt.max)); + cell.classList.add('is-filled'); + } + cell.title = DAYS[d] + ' ' + h + '시 · ' + n + '건'; + cell.setAttribute('aria-label', cell.title); + row.appendChild(cell); + }); + table.appendChild(row); + }); + patternEl.appendChild(table); + patternEl.appendChild(el('p', 'report-empty', '진할수록 감지가 몰린 시간대입니다 (최대 ' + pt.max + '건).')); + } + } + footEl.textContent = '생성 ' + new Date(r.generated_at * 1000).toLocaleString('ko-KR') + ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.'; } diff --git a/server/src/Dashboard/ReportController.php b/server/src/Dashboard/ReportController.php index f349042..2b51068 100644 --- a/server/src/Dashboard/ReportController.php +++ b/server/src/Dashboard/ReportController.php @@ -19,6 +19,14 @@ final class ReportController private const TOP_CAMERAS = 8; private const OCCUPANCY_TYPES = ['lone_worker', 'overcapacity', 'overstay']; private const ERGO_KEYS = ['observed_s', 'trunk_high_s', 'trunk_mod_s', 'arm_high_s', 'squat_s']; + // 전조(precursor) 그룹: 부상 없이 끝났지만 사고로 이어질 수 있던 신호 — 급증 경보와 + // 시간 패턴의 대상. 사고 자체(fall/no_motion/ghost/immobility/pinch)는 제외. + private const PRECURSOR_TYPES = ['near_fall', 'zone_warning', 'zone_restricted', + 'lone_worker', 'overcapacity', 'overstay']; + private const SPIKE_BASELINE_WEEKS = 4; + private const SPIKE_MIN_COUNT = 5; // 이보다 적으면 통계가 아니라 소음 + private const SPIKE_RATIO = 1.5; + private const PATTERN_LIMIT = 20000; public function __construct(private EventRepository $events, private SessionStore $sess) {} @@ -28,7 +36,12 @@ final class ReportController $days = self::clampDays($req->query('days')); $scope = $this->sess->get('site_scope'); $site = is_string($scope) ? $scope : null; - $s = $this->events->reportSummary((float)(time() - $days * 86400), $site, self::TOP_CAMERAS); + $now = time(); + $since = (float)($now - $days * 86400); + $s = $this->events->reportSummary($since, $site, self::TOP_CAMERAS); + // 직전 동일 길이 기간 — KPI의 "지난 기간 대비" 추이. 같은 집계를 상한만 걸어 재사용. + $prev = $this->events->reportSummary((float)($now - 2 * $days * 86400), $site, + self::TOP_CAMERAS, $since); $severityTotals = ['critical' => 0, 'high' => 0, 'warning' => 0, 'info' => 0]; $typeCounts = []; @@ -60,6 +73,24 @@ final class ReportController } $ergo = array_map(static fn ($v) => round($v, 1), $ergo); + // 이전 기간 요약 — KPI 추이에 필요한 축만 (전체 by_type 재전송은 낭비). + $prevSeverity = ['critical' => 0, 'high' => 0]; + $prevTotal = 0; $prevFp = 0; $prevNearFall = 0; + foreach ($prev['by_type'] as $row) { + if ($row['event_type'] === 'ergo_report') continue; + $prevTotal += $row['count']; + $prevFp += $row['fp']; + if (isset($prevSeverity[$row['severity']])) $prevSeverity[$row['severity']] += $row['count']; + if ($row['event_type'] === 'near_fall') $prevNearFall += $row['count']; + } + + // 시간 패턴 + 전조 급증. 버킷팅은 PHP에서(서버 로컬 시각 = 현장 시간대) — SQL은 + // 이식성 유지. 급증 기준선은 리포트 기간과 무관하게 최근 5주로 고정한다. + $pattern = self::buildPattern($this->events->occurrenceTimes($since, $site, self::PATTERN_LIMIT)); + $spike = self::precursorSpike( + $this->events->occurrenceTimes((float)($now - (self::SPIKE_BASELINE_WEEKS + 1) * 7 * 86400), + $site, self::PATTERN_LIMIT), $now); + return Response::json([ 'days' => $days, 'generated_at' => time(), @@ -75,9 +106,54 @@ final class ReportController 'fp' => ['tagged' => $fpTagged, 'total' => $total], 'ergo' => $ergo, 'occupancy' => $occupancy, + 'previous' => [ + 'events' => $prevTotal, + 'critical' => $prevSeverity['critical'], 'high' => $prevSeverity['high'], + 'near_fall' => $prevNearFall, + 'fp' => ['tagged' => $prevFp, 'total' => $prevTotal], + 'tta_avg_s' => $prev['tta']['avg_s'] !== null ? round($prev['tta']['avg_s'], 1) : null, + ], + 'pattern' => $pattern, + 'precursor_alert' => $spike, ], 200); } + /** 요일(0=월)×시간(0..23) 발생 밀도 그리드 — 서버 로컬 시각 기준. */ + private static function buildPattern(array $times): array + { + $grid = array_fill(0, 7, array_fill(0, 24, 0)); + $max = 0; + foreach ($times as $t) { + $ts = (int)$t['occurred_at']; + $d = (int)date('N', $ts) - 1; + $h = (int)date('G', $ts); + $grid[$d][$h]++; + if ($grid[$d][$h] > $max) $max = $grid[$d][$h]; + } + return ['grid' => $grid, 'max' => $max]; + } + + /** + * 전조 급증: 최근 7일 전조 건수를 그 이전 4주의 주평균과 비교. 사고가 나기 전에 올리는 + * 경보이므로 보수적으로 — 표본이 SPIKE_MIN_COUNT 미만이면 절대 울리지 않는다. + */ + private static function precursorSpike(array $times, int $now): array + { + $weekAgo = $now - 7 * 86400; + $thisWeek = 0; $baselineTotal = 0; + foreach ($times as $t) { + if (!in_array($t['event_type'], self::PRECURSOR_TYPES, true)) continue; + if ($t['occurred_at'] >= $weekAgo) $thisWeek++; + else $baselineTotal++; + } + $baselineAvg = $baselineTotal / self::SPIKE_BASELINE_WEEKS; + $ratio = $baselineAvg > 0 ? $thisWeek / $baselineAvg : null; + $alert = $thisWeek >= self::SPIKE_MIN_COUNT + && ($baselineAvg == 0.0 || $ratio >= self::SPIKE_RATIO); + return ['this_week' => $thisWeek, 'baseline_weekly_avg' => round($baselineAvg, 1), + 'ratio' => $ratio !== null ? round($ratio, 2) : null, 'alert' => $alert]; + } + private static function clampDays(mixed $raw): int { if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS; diff --git a/server/src/Repository/EventRepository.php b/server/src/Repository/EventRepository.php index 211c853..a010083 100644 --- a/server/src/Repository/EventRepository.php +++ b/server/src/Repository/EventRepository.php @@ -47,5 +47,12 @@ interface EventRepository * a dismissed false alarm is not an emergency response) * - ergo_metrics: raw metrics arrays of ergo_report rows (caller sums) */ - public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras): array; + public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras, + ?float $untilOccurredAt = null): array; + /** + * Bare (occurred_at, event_type) pairs of non-ergo events in the window, newest first, + * capped at $limit — bucketing (weekday×hour patterns, spike baselines) happens in PHP + * so the SQL stays portable (the sqlite integration harness has no FROM_UNIXTIME). + */ + public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array; } diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php index 54076ae..e3eb56f 100644 --- a/server/src/Repository/Pdo/PdoEventRepository.php +++ b/server/src/Repository/Pdo/PdoEventRepository.php @@ -207,9 +207,14 @@ final class PdoEventRepository implements EventRepository ], $st->fetchAll()); } - public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras): array + public function reportSummary(float $sinceOccurredAt, ?string $site, int $topCameras, + ?float $untilOccurredAt = null): array { $sinceStr = sprintf('%.6f', $sinceOccurredAt); + // Optional upper bound so the SAME aggregation can run over the preceding window + // (period-over-period trend) without duplicating any of these queries. + $until = $untilOccurredAt !== null ? ' AND occurred_at < ?' : ''; + $untilBind = $untilOccurredAt !== null ? [sprintf('%.6f', $untilOccurredAt)] : []; $scope = $site !== null ? ' AND site = ?' : ''; $scopeBind = $site !== null ? [$site] : []; @@ -217,19 +222,20 @@ final class PdoEventRepository implements EventRepository "SELECT event_type, severity, COUNT(*) AS count, SUM(acknowledged_by IS NULL) AS unacked, SUM(false_positive_by IS NOT NULL) AS fp - FROM move_events WHERE occurred_at >= ?$scope + FROM move_events WHERE occurred_at >= ?$until$scope GROUP BY event_type, severity" ); - $byType->execute([$sinceStr, ...$scopeBind]); + $byType->execute([$sinceStr, ...$untilBind, ...$scopeBind]); $byCamera = $this->pdo->prepare( "SELECT camera_id, COUNT(*) AS count, SUM(severity IN ('critical','high')) AS critical_high - FROM move_events WHERE occurred_at >= ?$scope + FROM move_events WHERE occurred_at >= ?$until$scope GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?" ); $pos = 1; $byCamera->bindValue($pos++, $sinceStr, PDO::PARAM_STR); + foreach ($untilBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR); foreach ($scopeBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR); $byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT); $byCamera->execute(); @@ -242,19 +248,19 @@ final class PdoEventRepository implements EventRepository AVG(acknowledged_at - occurred_at) AS avg_s, MAX(acknowledged_at - occurred_at) AS max_s FROM move_events - WHERE occurred_at >= ?$scope AND severity IN ('critical','high') + WHERE occurred_at >= ?$until$scope AND severity IN ('critical','high') AND acknowledged_at IS NOT NULL AND false_positive_by IS NULL AND acknowledged_at >= occurred_at" ); - $tta->execute([$sinceStr, ...$scopeBind]); + $tta->execute([$sinceStr, ...$untilBind, ...$scopeBind]); $ttaRow = $tta->fetch() ?: ['acked' => 0, 'avg_s' => null, 'max_s' => null]; $ergo = $this->pdo->prepare( "SELECT metrics_json FROM move_events - WHERE occurred_at >= ?$scope AND event_type = 'ergo_report' + WHERE occurred_at >= ?$until$scope AND event_type = 'ergo_report' ORDER BY occurred_at DESC LIMIT 2000" ); - $ergo->execute([$sinceStr, ...$scopeBind]); + $ergo->execute([$sinceStr, ...$untilBind, ...$scopeBind]); return [ 'by_type' => array_map(static fn ($r) => [ @@ -277,6 +283,27 @@ final class PdoEventRepository implements EventRepository ]; } + public function occurrenceTimes(float $sinceOccurredAt, ?string $site, int $limit): array + { + $sql = "SELECT occurred_at, event_type FROM move_events + WHERE occurred_at >= ? AND event_type <> 'ergo_report'"; + $binds = [[sprintf('%.6f', $sinceOccurredAt), PDO::PARAM_STR]]; + if ($site !== null) { + $sql .= ' AND site = ?'; + $binds[] = [$site, PDO::PARAM_STR]; + } + $sql .= ' ORDER BY occurred_at DESC LIMIT ?'; + $binds[] = [$limit, PDO::PARAM_INT]; + $st = $this->pdo->prepare($sql); + $pos = 1; + foreach ($binds as [$val, $type]) $st->bindValue($pos++, $val, $type); + $st->execute(); + return array_map(static fn ($r) => [ + 'occurred_at' => (float)$r['occurred_at'], + 'event_type' => $r['event_type'], + ], $st->fetchAll()); + } + private static function mapRow(array $row): array { return [ diff --git a/server/src/View/report.php b/server/src/View/report.php index 1404508..d7338b1 100644 --- a/server/src/View/report.php +++ b/server/src/View/report.php @@ -20,6 +20,7 @@ render_header('안전 리포트'); +
@@ -39,6 +40,10 @@ render_header('안전 리포트');

존 규칙 위반 · 처리 현황

+
+

시간 패턴 (요일 × 시간)

+
+

diff --git a/server/tests/ReportTest.php b/server/tests/ReportTest.php index 29306b6..a798820 100644 --- a/server/tests/ReportTest.php +++ b/server/tests/ReportTest.php @@ -89,6 +89,53 @@ final class ReportTest extends TestCase $this->assertArrayNotHasKey('ergo_report', $data['by_type']); } + public function testPreviousWindowTrendAndPattern(): void + { + $r = new InMemoryEventRepository(); + $now = (float)time(); + // 이번 7일: 중대 1건 / 직전 7일(7~14일 전): 중대 3건 + $r->insert(['camera_id' => 'c', 'event_type' => 'fall', 'severity' => 'high', + 'occurred_at' => $now - 3600, 'metrics' => []]); + foreach ([8, 9, 10] as $d) { + $r->insert(['camera_id' => 'c', 'event_type' => 'fall', 'severity' => 'high', + 'occurred_at' => $now - $d * 86400, 'metrics' => []]); + } + $data = $this->get(new ReportController($r, $this->sess())); + $this->assertSame(1, $data['totals']['high']); + $this->assertSame(3, $data['previous']['high']); // 직전 기간만, 이번 기간과 겹치지 않음 + // 시간 패턴: 이번 기간 1건이 그 발생 시각의 요일×시간 셀에 찍힌다 + $ts = (int)($now - 3600); + $d = (int)date('N', $ts) - 1; + $h = (int)date('G', $ts); + $this->assertSame(1, $data['pattern']['grid'][$d][$h]); + $this->assertSame(1, $data['pattern']['max']); + } + + public function testPrecursorSpikeAlert(): void + { + $r = new InMemoryEventRepository(); + $now = (float)time(); + // 기준선: 이전 4주에 주당 2건 (총 8건) / 최근 7일: 6건 → 3배, 표본 충분 → 경보 + for ($i = 0; $i < 8; $i++) { + $r->insert(['camera_id' => 'c', 'event_type' => 'zone_warning', 'severity' => 'warning', + 'occurred_at' => $now - (8 + $i * 3) * 86400, 'metrics' => []]); + } + for ($i = 0; $i < 6; $i++) { + $r->insert(['camera_id' => 'c', 'event_type' => 'near_fall', 'severity' => 'info', + 'occurred_at' => $now - $i * 3600 - 60, 'metrics' => []]); + } + $data = $this->get(new ReportController($r, $this->sess())); + $this->assertTrue($data['precursor_alert']['alert']); + $this->assertSame(6, $data['precursor_alert']['this_week']); + $this->assertEquals(2.0, $data['precursor_alert']['baseline_weekly_avg']); + // 표본 미달이면 비율이 높아도 경보 없음 + $quiet = new InMemoryEventRepository(); + $quiet->insert(['camera_id' => 'c', 'event_type' => 'near_fall', 'severity' => 'info', + 'occurred_at' => $now - 60, 'metrics' => []]); + $qd = $this->get(new ReportController($quiet, $this->sess())); + $this->assertFalse($qd['precursor_alert']['alert']); + } + public function testSiteScopeFiltersReport(): void { $data = $this->get(new ReportController($this->repo(), $this->sess('a'))); diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php index fdc4d10..82535a0 100644 --- a/server/tests/Support/InMemoryRepositories.php +++ b/server/tests/Support/InMemoryRepositories.php @@ -84,8 +84,9 @@ final class InMemoryEventRepository implements EventRepository { usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']); return $out; } - public function reportSummary(float $since, ?string $site, int $topCameras): array { + public function reportSummary(float $since, ?string $site, int $topCameras, ?float $until = null): array { $rows = array_filter($this->rows, fn($r) => ($r['occurred_at'] ?? 0) >= $since + && ($until === null || ($r['occurred_at'] ?? 0) < $until) && ($site === null || ($r['site'] ?? null) === $site)); $byType = []; $byCam = []; $ttaDeltas = []; $ergo = []; foreach ($rows as $r) { @@ -116,6 +117,14 @@ final class InMemoryEventRepository implements EventRepository { 'ergo_metrics' => $ergo, ]; } + public function occurrenceTimes(float $since, ?string $site, int $limit): array { + $r = array_filter($this->rows, fn($x) => ($x['occurred_at'] ?? 0) >= $since + && ($x['event_type'] ?? '') !== 'ergo_report' + && ($site === null || ($x['site'] ?? null) === $site)); + usort($r, fn($a, $b) => ($b['occurred_at'] ?? 0) <=> ($a['occurred_at'] ?? 0)); + return array_map(fn($x) => ['occurred_at' => (float)($x['occurred_at'] ?? 0), + 'event_type' => $x['event_type']], array_slice(array_values($r), 0, $limit)); + } public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array { $r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId && ($x['occurred_at'] ?? 0) >= $since