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).
This commit is contained in:
parent
51eed139cf
commit
d8b8e1f381
8 changed files with 285 additions and 19 deletions
|
|
@ -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); }
|
.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) {
|
@media (max-width:860px) {
|
||||||
.report-kpis { grid-template-columns:repeat(2, 1fr); }
|
.report-kpis { grid-template-columns:repeat(2, 1fr); }
|
||||||
.report-grid { grid-template-columns:1fr; }
|
.report-grid { grid-template-columns:1fr; }
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,21 @@
|
||||||
return t;
|
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) {
|
function barRow(label, value, max, text, hot) {
|
||||||
var row = el('div', 'bar-row');
|
var row = el('div', 'bar-row');
|
||||||
var head = el('div', 'bar-head');
|
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 - r.days * 86400) * 1000).toLocaleDateString('ko-KR')
|
||||||
+ ' ~ ' + new Date(r.generated_at * 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.textContent = '';
|
||||||
root.appendChild(kpi('중대 이벤트', String(severe),
|
var prev = r.previous || {};
|
||||||
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : ''));
|
var kSevere = kpi('중대 이벤트', String(severe),
|
||||||
root.appendChild(kpi('평균 대응 시간', fmtDuration(r.tta.avg_s),
|
'위급 ' + t.critical + ' · 높음 ' + t.high, severe > 0 ? 'is-severe' : '');
|
||||||
r.tta.acked > 0 ? '중대 ' + r.tta.acked + '건 확인 · 최장 ' + fmtDuration(r.tta.max_s) : '확인된 중대 이벤트 없음'));
|
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;
|
var nm = r.near_miss;
|
||||||
root.appendChild(kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
|
var kNm = kpi('니어미스 : 사고', nm.near_fall + ' : ' + nm.incidents,
|
||||||
nm.near_fall > 0 ? '사고 전 신호가 먼저 잡히고 있습니다' : '기록된 니어미스 없음'));
|
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;
|
var fpPct = r.fp.total > 0 ? Math.round(r.fp.tagged / r.fp.total * 100) : 0;
|
||||||
root.appendChild(kpi('오탐율', fpPct + '%',
|
var prevFpPct = prev.fp && prev.fp.total > 0 ? Math.round(prev.fp.tagged / prev.fp.total * 100) : null;
|
||||||
'전체 ' + r.fp.total + '건 중 오탐 태그 ' + r.fp.tagged + '건', fpPct >= 30 ? 'is-warn' : ''));
|
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 = '';
|
typesEl.textContent = '';
|
||||||
var typeKeys = Object.keys(r.by_type);
|
var typeKeys = Object.keys(r.by_type);
|
||||||
|
|
@ -142,6 +183,40 @@
|
||||||
});
|
});
|
||||||
rulesEl.appendChild(rules);
|
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')
|
footEl.textContent = '생성 ' + new Date(r.generated_at * 1000).toLocaleString('ko-KR')
|
||||||
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
|
+ ' · 본 리포트는 AI 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,14 @@ final class ReportController
|
||||||
private const TOP_CAMERAS = 8;
|
private const TOP_CAMERAS = 8;
|
||||||
private const OCCUPANCY_TYPES = ['lone_worker', 'overcapacity', 'overstay'];
|
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'];
|
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) {}
|
public function __construct(private EventRepository $events, private SessionStore $sess) {}
|
||||||
|
|
||||||
|
|
@ -28,7 +36,12 @@ final class ReportController
|
||||||
$days = self::clampDays($req->query('days'));
|
$days = self::clampDays($req->query('days'));
|
||||||
$scope = $this->sess->get('site_scope');
|
$scope = $this->sess->get('site_scope');
|
||||||
$site = is_string($scope) ? $scope : null;
|
$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];
|
$severityTotals = ['critical' => 0, 'high' => 0, 'warning' => 0, 'info' => 0];
|
||||||
$typeCounts = [];
|
$typeCounts = [];
|
||||||
|
|
@ -60,6 +73,24 @@ final class ReportController
|
||||||
}
|
}
|
||||||
$ergo = array_map(static fn ($v) => round($v, 1), $ergo);
|
$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([
|
return Response::json([
|
||||||
'days' => $days,
|
'days' => $days,
|
||||||
'generated_at' => time(),
|
'generated_at' => time(),
|
||||||
|
|
@ -75,9 +106,54 @@ final class ReportController
|
||||||
'fp' => ['tagged' => $fpTagged, 'total' => $total],
|
'fp' => ['tagged' => $fpTagged, 'total' => $total],
|
||||||
'ergo' => $ergo,
|
'ergo' => $ergo,
|
||||||
'occupancy' => $occupancy,
|
'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);
|
], 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
|
private static function clampDays(mixed $raw): int
|
||||||
{
|
{
|
||||||
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
|
if ($raw === null || $raw === '' || !is_numeric($raw)) return self::DEFAULT_DAYS;
|
||||||
|
|
|
||||||
|
|
@ -47,5 +47,12 @@ interface EventRepository
|
||||||
* a dismissed false alarm is not an emergency response)
|
* a dismissed false alarm is not an emergency response)
|
||||||
* - ergo_metrics: raw metrics arrays of ergo_report rows (caller sums)
|
* - 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,14 @@ final class PdoEventRepository implements EventRepository
|
||||||
], $st->fetchAll());
|
], $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);
|
$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 = ?' : '';
|
$scope = $site !== null ? ' AND site = ?' : '';
|
||||||
$scopeBind = $site !== null ? [$site] : [];
|
$scopeBind = $site !== null ? [$site] : [];
|
||||||
|
|
||||||
|
|
@ -217,19 +222,20 @@ final class PdoEventRepository implements EventRepository
|
||||||
"SELECT event_type, severity, COUNT(*) AS count,
|
"SELECT event_type, severity, COUNT(*) AS count,
|
||||||
SUM(acknowledged_by IS NULL) AS unacked,
|
SUM(acknowledged_by IS NULL) AS unacked,
|
||||||
SUM(false_positive_by IS NOT NULL) AS fp
|
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"
|
GROUP BY event_type, severity"
|
||||||
);
|
);
|
||||||
$byType->execute([$sinceStr, ...$scopeBind]);
|
$byType->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
|
||||||
|
|
||||||
$byCamera = $this->pdo->prepare(
|
$byCamera = $this->pdo->prepare(
|
||||||
"SELECT camera_id, COUNT(*) AS count,
|
"SELECT camera_id, COUNT(*) AS count,
|
||||||
SUM(severity IN ('critical','high')) AS critical_high
|
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 ?"
|
GROUP BY camera_id ORDER BY critical_high DESC, count DESC LIMIT ?"
|
||||||
);
|
);
|
||||||
$pos = 1;
|
$pos = 1;
|
||||||
$byCamera->bindValue($pos++, $sinceStr, PDO::PARAM_STR);
|
$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);
|
foreach ($scopeBind as $v) $byCamera->bindValue($pos++, $v, PDO::PARAM_STR);
|
||||||
$byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT);
|
$byCamera->bindValue($pos, $topCameras, PDO::PARAM_INT);
|
||||||
$byCamera->execute();
|
$byCamera->execute();
|
||||||
|
|
@ -242,19 +248,19 @@ final class PdoEventRepository implements EventRepository
|
||||||
AVG(acknowledged_at - occurred_at) AS avg_s,
|
AVG(acknowledged_at - occurred_at) AS avg_s,
|
||||||
MAX(acknowledged_at - occurred_at) AS max_s
|
MAX(acknowledged_at - occurred_at) AS max_s
|
||||||
FROM move_events
|
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 IS NOT NULL AND false_positive_by IS NULL
|
||||||
AND acknowledged_at >= occurred_at"
|
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];
|
$ttaRow = $tta->fetch() ?: ['acked' => 0, 'avg_s' => null, 'max_s' => null];
|
||||||
|
|
||||||
$ergo = $this->pdo->prepare(
|
$ergo = $this->pdo->prepare(
|
||||||
"SELECT metrics_json FROM move_events
|
"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"
|
ORDER BY occurred_at DESC LIMIT 2000"
|
||||||
);
|
);
|
||||||
$ergo->execute([$sinceStr, ...$scopeBind]);
|
$ergo->execute([$sinceStr, ...$untilBind, ...$scopeBind]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'by_type' => array_map(static fn ($r) => [
|
'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
|
private static function mapRow(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ render_header('안전 리포트');
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div id="report-alert" class="report-spike" hidden role="alert"></div>
|
||||||
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
|
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
|
||||||
|
|
||||||
<div class="report-grid">
|
<div class="report-grid">
|
||||||
|
|
@ -39,6 +40,10 @@ render_header('안전 리포트');
|
||||||
<h2>존 규칙 위반 · 처리 현황</h2>
|
<h2>존 규칙 위반 · 처리 현황</h2>
|
||||||
<div id="report-rules"></div>
|
<div id="report-rules"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="report-card report-card-wide">
|
||||||
|
<h2>시간 패턴 (요일 × 시간)</h2>
|
||||||
|
<div id="report-pattern"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p id="report-foot" class="report-foot"></p>
|
<p id="report-foot" class="report-foot"></p>
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,53 @@ final class ReportTest extends TestCase
|
||||||
$this->assertArrayNotHasKey('ergo_report', $data['by_type']);
|
$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
|
public function testSiteScopeFiltersReport(): void
|
||||||
{
|
{
|
||||||
$data = $this->get(new ReportController($this->repo(), $this->sess('a')));
|
$data = $this->get(new ReportController($this->repo(), $this->sess('a')));
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,9 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
|
usort($out, fn($a, $b) => $b['fp'] <=> $a['fp']);
|
||||||
return $out;
|
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
|
$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));
|
&& ($site === null || ($r['site'] ?? null) === $site));
|
||||||
$byType = []; $byCam = []; $ttaDeltas = []; $ergo = [];
|
$byType = []; $byCam = []; $ttaDeltas = []; $ergo = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
|
|
@ -116,6 +117,14 @@ final class InMemoryEventRepository implements EventRepository {
|
||||||
'ergo_metrics' => $ergo,
|
'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 {
|
public function heatmapPoints(string $cameraId, float $since, ?string $site, int $limit): array {
|
||||||
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
$r = array_filter($this->rows, fn($x) => ($x['camera_id'] ?? '') === $cameraId
|
||||||
&& ($x['occurred_at'] ?? 0) >= $since
|
&& ($x['occurred_at'] ?? 0) >= $since
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue