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('안전 리포트'); +