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:
유창욱 2026-08-07 07:35:30 +09:00
parent 51eed139cf
commit d8b8e1f381
8 changed files with 285 additions and 19 deletions

View file

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

View file

@ -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 감지 기반 보조 지표이며 공식 재해 통계를 대체하지 않습니다.';
}

View file

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

View file

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

View file

@ -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 [

View file

@ -20,6 +20,7 @@ render_header('안전 리포트');
</div>
</header>
<div id="report-alert" class="report-spike" hidden role="alert"></div>
<div id="report-kpis" class="report-kpis" aria-label="핵심 지표"></div>
<div class="report-grid">
@ -39,6 +40,10 @@ render_header('안전 리포트');
<h2>존 규칙 위반 · 처리 현황</h2>
<div id="report-rules"></div>
</section>
<section class="report-card report-card-wide">
<h2>시간 패턴 (요일 × 시간)</h2>
<div id="report-pattern"></div>
</section>
</div>
<p id="report-foot" class="report-foot"></p>

View file

@ -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')));

View file

@ -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