feat: implement rolling half-hour report windows anchored to service startup time

This commit is contained in:
2026-05-12 16:17:08 +08:00
parent 4e69eca7cb
commit 454b716f89
6 changed files with 112 additions and 65 deletions

View File

@@ -19,6 +19,7 @@ def test_session_pauses_without_adding_absence_time():
def test_engine_emits_half_hour_report_with_queue_classification():
start = datetime(2026, 4, 15, 11, 7, tzinfo=TZ)
engine = DwellEngine(
camera_id="store_cam_01",
queue_time_threshold_seconds=300,
@@ -26,8 +27,8 @@ def test_engine_emits_half_hour_report_with_queue_classification():
normal_count_threshold=2,
pause_timeout_seconds=300,
alert_cooldown_seconds=600,
report_window_start=start,
)
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
crowded_group = [
{"person_id": f"cust_{idx}", "role": "customer"} for idx in range(6)
]
@@ -35,16 +36,18 @@ def test_engine_emits_half_hour_report_with_queue_classification():
{"person_id": f"short_{idx}", "role": "customer"} for idx in range(2)
]
engine.process_observations(crowded_group, start.replace(minute=0, second=0))
engine.process_observations(crowded_group, start.replace(minute=6, second=0))
engine.process_observations(crowded_group, start.replace(minute=7, second=0))
engine.process_observations(crowded_group, start.replace(minute=13, second=0))
engine.process_observations(
crowded_group + short_wait_group, start.replace(minute=27, second=0)
crowded_group + short_wait_group, start.replace(minute=34, second=0)
)
events = engine.process_observations(
short_wait_group, start.replace(minute=30, second=0)
short_wait_group, start.replace(minute=37, second=0)
)
report = next(event for event in events if event["event"] == "half_hour_report")
assert report["window_start"] == "2026-04-15T11:07:00+08:00"
assert report["window_end"] == "2026-04-15T11:37:00+08:00"
assert report["queue_metrics"]["over_threshold_count"] == 6
assert report["queue_metrics"]["under_threshold_count"] == 2
assert report["queue_metrics"]["queue_level"] == "crowded"
@@ -52,6 +55,7 @@ def test_engine_emits_half_hour_report_with_queue_classification():
def test_engine_tracks_queue_status_change_between_windows():
start = datetime(2026, 4, 15, 11, 7, tzinfo=TZ)
engine = DwellEngine(
camera_id="store_cam_01",
queue_time_threshold_seconds=300,
@@ -59,28 +63,31 @@ def test_engine_tracks_queue_status_change_between_windows():
normal_count_threshold=2,
pause_timeout_seconds=300,
alert_cooldown_seconds=600,
report_window_start=start,
)
start = datetime(2026, 4, 15, 11, 0, tzinfo=TZ)
engine.process_observations(
[{"person_id": f"crowded_{idx}", "role": "customer"} for idx in range(6)],
start,
)
engine.process_observations([], start.replace(minute=30))
engine.process_observations([], start.replace(minute=37))
engine.process_observations(
[{"person_id": f"normal_{idx}", "role": "customer"} for idx in range(3)],
start.replace(minute=31),
start.replace(minute=38),
)
report_events = engine.process_observations([], start.replace(hour=12, minute=0))
report_events = engine.process_observations([], start.replace(hour=12, minute=7))
report = next(
event for event in report_events if event["event"] == "half_hour_report"
)
assert report["window_start"] == "2026-04-15T11:37:00+08:00"
assert report["window_end"] == "2026-04-15T12:07:00+08:00"
assert report["queue_metrics"]["queue_level"] == "normal"
assert report["queue_metrics"]["status_change"] == "queue_normalized"
assert report["queue_metrics"]["previous_queue_level"] == "crowded"
def test_engine_emits_half_hour_report_with_closed_customers():
report_window_start = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
engine = DwellEngine(
camera_id="store_cam_01",
queue_time_threshold_seconds=300,
@@ -88,16 +95,18 @@ def test_engine_emits_half_hour_report_with_closed_customers():
normal_count_threshold=2,
pause_timeout_seconds=300,
alert_cooldown_seconds=600,
report_window_start=report_window_start,
)
seen_at = datetime(2026, 4, 15, 11, 10, tzinfo=TZ)
engine.process_observations([{"person_id": "cust_1", "role": "customer"}], seen_at)
engine.process_observations([], datetime(2026, 4, 15, 11, 12, tzinfo=TZ))
engine.process_observations([], datetime(2026, 4, 15, 11, 18, tzinfo=TZ))
events = engine.process_observations([], datetime(2026, 4, 15, 11, 30, tzinfo=TZ))
events = engine.process_observations([], datetime(2026, 4, 15, 11, 40, tzinfo=TZ))
report = next(event for event in events if event["event"] == "half_hour_report")
assert report["window_end"] == "2026-04-15T11:30:00+08:00"
assert report["window_start"] == "2026-04-15T11:10:00+08:00"
assert report["window_end"] == "2026-04-15T11:40:00+08:00"
assert report["closed_customers"][0]["person_id"] == "cust_1"
assert report["queue_metrics"]["over_threshold_count"] == 0
assert report["queue_metrics"]["under_threshold_count"] == 1

View File

@@ -1,15 +1,26 @@
from datetime import datetime
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from app.modules.reporter import floor_half_hour, should_emit_half_hour_report
from app.modules.reporter import initial_half_hour_window, should_emit_half_hour_report
def test_half_hour_report_emits_on_half_hour_boundaries():
assert should_emit_half_hour_report("2026-04-15T11:00:00+08:00") is True
assert should_emit_half_hour_report("2026-04-15T11:30:00+08:00") is True
assert should_emit_half_hour_report("2026-04-15T11:17:00+08:00") is False
def test_half_hour_report_emits_after_rolling_1800_seconds():
started_at = datetime(2026, 4, 15, 11, 17, 35, tzinfo=ZoneInfo("Asia/Shanghai"))
_, window_end = initial_half_hour_window(started_at)
assert (
should_emit_half_hour_report(window_end, started_at + timedelta(seconds=1799))
is False
)
assert (
should_emit_half_hour_report(window_end, started_at + timedelta(seconds=1800))
is True
)
def test_floor_half_hour_rounds_down():
dt = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
assert floor_half_hour(dt).isoformat() == "2026-04-15T11:30:00+08:00"
def test_initial_half_hour_window_preserves_startup_offset():
started_at = datetime(2026, 4, 15, 11, 47, 13, tzinfo=ZoneInfo("Asia/Shanghai"))
window_start, window_end = initial_half_hour_window(started_at)
assert window_start.isoformat() == "2026-04-15T11:47:13+08:00"
assert window_end.isoformat() == "2026-04-15T12:17:13+08:00"