diff --git a/managed/store_dwell_alert/app/main.py b/managed/store_dwell_alert/app/main.py index b376460..4066eff 100644 --- a/managed/store_dwell_alert/app/main.py +++ b/managed/store_dwell_alert/app/main.py @@ -26,6 +26,7 @@ def build_app(config_path: str | Path | None = None) -> dict: project_root = resolve_project_root(resolved_config_path) event_sink_path = resolve_project_path(project_root, config.event_sink.path) gallery_dir = resolve_project_path(project_root, config.staff.gallery_dir) + startup_time = datetime.now().astimezone() return { "config": config, @@ -51,6 +52,7 @@ def build_app(config_path: str | Path | None = None) -> dict: normal_count_threshold=config.thresholds.normal_count_threshold, pause_timeout_seconds=config.thresholds.pause_timeout_seconds, alert_cooldown_seconds=config.thresholds.alert_cooldown_seconds, + report_window_start=startup_time, ), "notifier": lambda path, event: dispatch_json_event( path, diff --git a/managed/store_dwell_alert/app/modules/dwell_engine.py b/managed/store_dwell_alert/app/modules/dwell_engine.py index e42d811..d4f89a3 100644 --- a/managed/store_dwell_alert/app/modules/dwell_engine.py +++ b/managed/store_dwell_alert/app/modules/dwell_engine.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime -from app.modules.reporter import floor_half_hour, previous_half_hour_window +from app.modules.reporter import initial_half_hour_window, should_emit_half_hour_report @dataclass(slots=True) @@ -105,6 +105,7 @@ class DwellEngine: normal_count_threshold: int, pause_timeout_seconds: int, alert_cooldown_seconds: int, + report_window_start: datetime | None = None, ) -> None: self.camera_id = camera_id self.queue_time_threshold_seconds = queue_time_threshold_seconds @@ -117,8 +118,13 @@ class DwellEngine: self.session_counts: dict[str, int] = {} self.alert_rearmed = True self.last_alert_at: datetime | None = None - self.last_report_boundary: datetime | None = None self.last_queue_level: str | None = None + self.report_window_start: datetime | None = None + self.report_window_end: datetime | None = None + if report_window_start is not None: + self.report_window_start, self.report_window_end = initial_half_hour_window( + report_window_start + ) def _next_session_id(self, person_id: str) -> str: next_index = self.session_counts.get(person_id, 0) + 1 @@ -140,6 +146,7 @@ class DwellEngine: def process_observations( self, observations: list[dict], when: datetime ) -> list[dict]: + self._ensure_report_window(when) events: list[dict] = [] seen_people: set[str] = set() @@ -168,12 +175,21 @@ class DwellEngine: if alert_event is not None: events.append(alert_event) - report_event = self._build_half_hour_report(when) - if report_event is not None: + while True: + report_event = self._build_half_hour_report(when) + if report_event is None: + break events.append(report_event) return events + def _ensure_report_window(self, when: datetime) -> None: + if self.report_window_start is not None and self.report_window_end is not None: + return + self.report_window_start, self.report_window_end = initial_half_hour_window( + when + ) + def _active_customer_sessions(self, when: datetime) -> list[DwellSession]: return [ session @@ -212,27 +228,32 @@ class DwellEngine: } def _build_half_hour_report(self, when: datetime) -> dict | None: - boundary = floor_half_hour(when) - if boundary == when and self.last_report_boundary == boundary: - return - if boundary == self.last_report_boundary: - return None - if when < boundary: + self._ensure_report_window(when) + if self.report_window_start is None or self.report_window_end is None: return None - window_start, window_end = previous_half_hour_window(when) - queue_totals = self._queue_totals(window_start, window_end, when) + window_start = self.report_window_start + window_end = self.report_window_end + if not should_emit_half_hour_report(window_end, when): + return None + + queue_totals = self._queue_totals(window_start, window_end) queue_metrics = self._build_queue_metrics(queue_totals) - active_customers = [ - { - **session.as_event_dict(when), - "window_queue_seconds": session.window_dwell_seconds( - window_start, window_end, when - ), - } - for session in self.sessions.values() - if session.role == "customer" and session.state == "active" - ] + active_customers = [] + for session in self.sessions.values(): + if session.role != "customer" or session.state != "active": + continue + window_queue_seconds = session.window_dwell_seconds( + window_start, window_end, window_end + ) + if window_queue_seconds <= 0: + continue + active_customers.append( + { + **session.as_event_dict(window_end), + "window_queue_seconds": window_queue_seconds, + } + ) closed_customers = [ { "person_id": session.person_id, @@ -248,9 +269,14 @@ class DwellEngine: and window_start < session.closed_at <= window_end ] staff_seen_count = sum( - 1 for session in self.sessions.values() if session.role == "staff" + 1 + for session in self.sessions.values() + if session.role == "staff" + and session.window_dwell_seconds(window_start, window_end, window_end) > 0 + ) + self.report_window_start, self.report_window_end = initial_half_hour_window( + window_end ) - self.last_report_boundary = boundary return { "event": "half_hour_report", "project_type": "store_dwell_alert", @@ -269,7 +295,6 @@ class DwellEngine: self, window_start: datetime, window_end: datetime, - when: datetime, ) -> dict[str, int]: totals: dict[str, int] = {} for session in self.closed_sessions: @@ -287,7 +312,7 @@ class DwellEngine: if session.role != "customer": continue window_seconds = session.window_dwell_seconds( - window_start, window_end, when + window_start, window_end, window_end ) if window_seconds > 0: totals[session.person_id] = ( diff --git a/managed/store_dwell_alert/app/modules/reporter.py b/managed/store_dwell_alert/app/modules/reporter.py index 7c8fd0c..ab0a01e 100644 --- a/managed/store_dwell_alert/app/modules/reporter.py +++ b/managed/store_dwell_alert/app/modules/reporter.py @@ -2,18 +2,12 @@ from __future__ import annotations from datetime import datetime, timedelta - -def should_emit_half_hour_report(ts: str) -> bool: - dt = datetime.fromisoformat(ts) - return dt.minute in {0, 30} and dt.second == 0 +HALF_HOUR_REPORT_SECONDS = 1800 -def floor_half_hour(dt: datetime) -> datetime: - minute = 0 if dt.minute < 30 else 30 - return dt.replace(minute=minute, second=0, microsecond=0) +def should_emit_half_hour_report(window_end: datetime, when: datetime) -> bool: + return when >= window_end -def previous_half_hour_window(dt: datetime) -> tuple[datetime, datetime]: - window_end = floor_half_hour(dt) - window_start = window_end - timedelta(minutes=30) - return window_start, window_end +def initial_half_hour_window(started_at: datetime) -> tuple[datetime, datetime]: + return started_at, started_at + timedelta(seconds=HALF_HOUR_REPORT_SECONDS) diff --git a/managed/store_dwell_alert/tests/test_dwell_engine.py b/managed/store_dwell_alert/tests/test_dwell_engine.py index cd85fa5..4d4f609 100644 --- a/managed/store_dwell_alert/tests/test_dwell_engine.py +++ b/managed/store_dwell_alert/tests/test_dwell_engine.py @@ -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 diff --git a/managed/store_dwell_alert/tests/test_reporter.py b/managed/store_dwell_alert/tests/test_reporter.py index 02477e7..06fa560 100644 --- a/managed/store_dwell_alert/tests/test_reporter.py +++ b/managed/store_dwell_alert/tests/test_reporter.py @@ -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" diff --git a/tasks/todo.md b/tasks/todo.md index 4dd7fcc..f9e3954 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -4,11 +4,11 @@ - [x] Confirm the current `store_dwell_alert` half-hour report path and identify the runtime control point. - [x] Verify the plan covers behavior change, focused tests, deployment scope, and post-deploy validation. -- [ ] Update focused tests so `half_hour_report` is expected on rolling 1800-second windows from startup time. -- [ ] Implement the rolling window behavior in `store_dwell_alert` runtime code. -- [ ] Run focused `store_dwell_alert` tests for the changed slice. -- [ ] Deploy the updated `store_dwell_alert` code to `xiaozheng@10.8.0.11` and restart only the affected service(s). -- [ ] Validate the remote deployment and update the Review section with evidence. +- [x] Update focused tests so `half_hour_report` is expected on rolling 1800-second windows from startup time. +- [x] Implement the rolling window behavior in `store_dwell_alert` runtime code. +- [x] Run focused `store_dwell_alert` tests for the changed slice. +- [x] Deploy the updated `store_dwell_alert` code to `xiaozheng@10.8.0.11` and restart only the affected service(s). +- [x] Validate the remote deployment and update the Review section with evidence. ## Scope And Risks @@ -26,6 +26,12 @@ ## Review -- Status: in progress. -- Result: pending. -- Verification: pending. +- Status: completed. +- Result: `store_dwell_alert` now emits `half_hour_report` on rolling 1800-second windows anchored to service startup instead of natural `:00` / `:30` boundaries; the updated runtime files were deployed to `xiaozheng@10.8.0.11`, and the rebuilt `store-dwell-alert` container is healthy. +- Verification: + - updated focused expectations in `managed/store_dwell_alert/tests/test_reporter.py` and `managed/store_dwell_alert/tests/test_dwell_engine.py` to assert startup-relative windows such as `11:07 -> 11:37` instead of natural half-hour boundaries; + - ran `pytest tests/test_reporter.py tests/test_dwell_engine.py` under `managed/store_dwell_alert` and got `6 passed`; + - ran the broader `pytest tests` suite under `managed/store_dwell_alert` and observed unrelated pre-existing failures in `tests/test_main_smoke.py` and `tests/test_manage_api.py` caused by legacy config/test data issues such as `Thresholds.__init__() got an unexpected keyword argument 'min_people'` and `NameError: name 'null' is not defined`; the changed report-window tests still passed in that run; + - synced `managed/store_dwell_alert/app/main.py`, `managed/store_dwell_alert/app/modules/dwell_engine.py`, and `managed/store_dwell_alert/app/modules/reporter.py` to `/home/xiaozheng/managed-portal` on `10.8.0.11` and verified remote SHA256 matches local copies; + - rebuilt only `store-dwell-alert` with `docker compose --env-file managed-portal.10.8.0.11.env up -d --build store-dwell-alert` on the remote host; + - confirmed remote status after deploy: container `store-dwell-alert` is `running` and `healthy`, created at `2026-05-12 16:14:01 +0800 CST`, and recent logs show the Flask manage API serving plus successful `/api/manage/health` responses.