feat(statistics): usual-weekday baselines (#88) #90
No reviewers
Labels
No labels
blocked
bug
enhancement
high-priority
low-priority
needs-info
needs-triage
ready-for-agent
ready-for-human
referenced
research
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gabogg/hikcentral!90
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/statistics-usual-weekday-baselines"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements #88's usual-weekday baseline for hourly visitors and open-window dayparts. Also fixes #77 item 2: the admin dayparts route now defaults to the active business day before the reset. This PR is stacked on #89.
Architectural impact
The existing analytics service selects up to four trusted, non-holiday, gap-free matching weekdays from the previous eight weeks. Both admin routes gain
baseline=same_weekday_4w; authenticated read-only presenter wrappers live under/api/statistics/. Pydantic responses include source dates, unavailable reasons, and day quality. No schema migration.Verification
pytest -q: full suite passed after review fixes.Checklist
master.Depends on #89. Refs #80, #88, #77.
Review follow-up
Baseline provenance now lists only usable days. Trust follows the current unsuperseded verdict, and dwell averages are weighted by ingress. Volume-based dayparts reject a baseline because each source day has different time cuts. The selector still looks back up to eight weeks to obtain four samples;
same_weekday_4wdenotes the maximum sample count.WIP: feat(statistics): usual-weekday baselines (#88)to feat(statistics): usual-weekday baselines (#88)Code review — pass 1 (two-axis)
Reviewed the PR's own diff (
git diff <base>...<head>); Standards and Spec ran as independent passes and are reported separately, not reranked. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all to be fixed on the branch.Standards
Bugs and correctness
app/services/analytics_service.py:411-442: the dayparts baseline reports provenance that doesn't match what it averaged.usabledrops samples that don't have exactly 3 dayparts, but the response still sendsdates=dates(every candidate day), which defeats #88's provenance requirement. Return only the dates actually used. Also,mean_share_percentaverages over samples wheretotal > 0, whilemean_visitorsandmean_dwell_minutesaverage over all ofusable, so the three figures can come from different sample sets.app/db/occupancy_repository.py:1270-1281:is_cycle_data_trusted_asynconly readsAUTOMATIC_NOCTURNALrows and has nosuperseded_by IS NULLcheck. The existing trust selection (~line 1685) ranksRETROACTIVE_GUARD_AUDITand manual verdicts above the automatic one, so the two trust checks can disagree about the same cycle. Resolve or document.Documented-standard issues
app/services/analytics_service.py:650-685: the hourly baseline is built as untyped nested dicts even thoughUsualHourlyBaselineandPresenterHourlyResponseexist in this PR. code-standards.md §2.3: "Avoid passing untyped, raw dictionaries … when structured schemas are available". The dayparts path builds Pydantic models for the same thing. (Judgement: the surrounding function already returns a dict.)tests/test_statistics_baselines.py: code-standards.md §4.3 requires tests for every new endpoint/fix. Tests check sourcedatesand the #77 default, but not: theINSUFFICIENT_USUAL_WEEKDAYSreason; the averaged values (mean_visitors, shares); the 422 cases (open day,view=week,bucket_minutesnot 15/30/60).WIP:prefix.Baseline smells (all judgement calls)
analytics_service.py:datetime.date.fromisoformat(facility_cycle_bounds(None, reset).label)is newly repeated at lines 93, 120 and ~306 (already at 168 and 205). Extract anactive_business_day(reset)helper. The config-read-then-resetpreamble invalidate_closed_statistics_day_asyncrepeats the callers' config read, so every presenter request reads config twice.except ValueError → HTTPException(422, VALIDATION_ERROR)block atstatistics_controller.py:60,75andanalytics_controller.py:40,99. One shared helper or exception handler.baselineasLiteral["same_weekday_4w"], but the service takesbaseline: str | None(~300, ~452), andreasonis a bare string. Share oneLiteral/enum alias inapp/schemas/.get_hourly_timeseries_async/get_dwell_dayparts_async(~408, ~660), re-running cycle-quality and config queries per date. Acceptable for 4 dates; a narrower per-day aggregate would be clearer.same_weekday_4wpromises "4 weeks", but the search goes back 8 weeks (lines 89-115 and API docs). It means "up to 4 samples from 8 weeks".date(~409) shadows-in-reading thedatetype; rename tosample_day.Spec
Overall the PR matches the spec. Both admin routes take
baseline=same_weekday_4w. The login-only wrappers live under/api/statistics/, the response lists the source dates, and fewer than two usable days returnsnullwith a reason. The #77 item 2 default is fixed.(a) Missing or partial
require_auth)."tests/test_statistics_baselines.pyonly logs in asadmin, so nothing proves a viewer can reach/api/statistics/timeseries/hourlyand/dwell/dayparts.nullplus a reason)." TheINSUFFICIENT_USUAL_WEEKDAYSbranches (analytics_service.py:404,:414) are never run. The daypart baseline test checks onlydates, notmean_visitors,mean_dwell_minutesormean_share_percent.analytics_service.py:117),view=weekwith a baseline, or unsupported bucket sizes (:650).(b) Scope creep
gap_estimated,data_trustandcycle_completenessto both responses follows the shared constraint "Every period-level figure carries its data quality".(c) Implemented but looks wrong
analytics_service.py:411, samples without three dayparts are dropped, butdatesstill lists every selected day.mean_dwell_minutes(:431) averages each day's dwell equally. Dayparts withcount_in == 0add0.0, and busy days get no extra weight. This clashes with the fix in1a3f89b(PR #92), which weights dwell by open-window visitors.mode=volumeeach day has its own daypart cut times, so dayparts are averaged by index alone.UsualDaypartcarries no label, so the deck can't show which times the usual split covers.Open design points
is_cycle_data_trusted_async(occupancy_repository.py:1270) uses theis_trustedverdict, notdata_trust_score. AMANUAL_ADMINaudit forces it to true (occupancy_service.py:1098). A day with no audit row is silently excluded. None of this is documented.analytics_service.py:105).Summary: Standards 11 (4×P2, 7×P3) — worst: daypart baseline
dateslists days that weren't averaged. Spec 6 (2×P2, 4×P3) + 1 questionable design point — worst: no test for theINSUFFICIENT_USUAL_WEEKDAYSpath or non-admin access.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at head
22490be. Each axis verified every pass-1 finding against the code (not the commit messages), then reviewed the fix commits for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per review policy, from pass 2 on P1/P2 block merge; P3s go to one follow-up issue for this PR.Standards
Scope: fix commits
b414ad3,1d34b1f,22490bevs pass-1 headf474b12, plus a re-read of the full own diff.tests/test_statistics_baselines.pypasses in a throwaway worktree;ruff checkclean.Pass-1 findings
dateslisted days not averaged; share used a different sample setb414ad3,analytics_service.py:427-433:usablerequires 3 parts and entries > 0;used_datesreturned in both branches. Shares, visitors, dwell all average overusable.is_cycle_data_trusted_asyncread only the automatic verdict, skippedsuperseded_byoccupancy_repository.py:1311-1325filtersis_current = 1 AND superseded_by IS NULL, same priority order as the trusted-history query at:1729. Documented indocs/api/README.md.analytics_service.py:679-709buildsUsualHourlyBaseline/UsualHourlyBucket, then.model_dump(mode="json").view=week,bucket_minutes=20,mode=volume, operator access; hourlymean_visitorsand daypart visitor/share sums asserted. Not covered:mean_dwell_minutes(new weighted dwell) and the new trust-priority rule (manual/retroactive verdicts, superseded rows).WIP:title vs "draft" wordinganalytics_service.py:96, 123, 313.ValueError→ 422 blocks in controllersstatistics_controller.py(40, 62, 77).baseline: str | Noneand bare-stringreasonschemas/statistics.py:78stillreason: str | None.same_weekday_4wsuggests 4 weeksdatesample_day(b414ad3).New findings
P2
app/controllers/statistics_controller.py:51(added inb414ad3): viewer-facingbucket_minutes: int = Query(default=60)has no bounds or allowed set; the service only validates it whenbaselineis set (analytics_service.py:676). Reproduced in the worktree as an operator:bucket_minutes=0withcalibration_mode='FLOW_RATE_DENSITY'→ 500 (ZeroDivisionErroratanalytics_service.py:625).bucket_minutes=-5→ 200 with 1440 one-minute buckets, response reportingbucket_minutes: -5.Breaks AGENTS.md §2 ("Validate input strictly") and the
{detail, error_code}error contract. Type itLiteral[15, 30, 60](orLiteral[15, 30, 60, 1440]without a baseline).P3
occupancy_repository.py:1318-1321and:1729-1731: the verdict-priorityCASEis written twice and must change in step with the ranking at:2055(Duplicated Code). Extract one SQL fragment/constant.P3
analytics_service.py:462-464:if shares else 0.0is now dead (usablealways has ≥2 entries with total > 0). Remove.P3
tests/test_statistics_baselines.py:111-112: sum of 1-decimal-rounded values compared with== 100.0; fragile. Usepytest.approx(100.0, abs=0.2).P3
analytics_service.py:676: bucket-size check runs after the target day's full bucket computation; validate first.Merge readiness (this axis): Blocked by one new P2 (unbounded viewer
bucket_minutes→ 500) and the partial P2 on tests (weighted dwell, trust priority). P3s → follow-up issue.Spec
Checked
b414ad3,1d34b1f,22490beagainst the branch head.tests/test_statistics_baselines.pypasses in a throwaway worktree.Pass-1 findings
1d34b1f, test lines 197-205:operator(non-admin) gets 200 on both/api/statistics/routes.INSUFFICIENT_USUAL_WEEKDAYSbranch (no candidates,dates == []). The second branch — candidates exist but fewer than 2 usable — is never run. Lines 101/111 checkmean_visitors(47.5). The share check (112) always holds because every seeded event is at 10:00 (one daypart at 100%).mean_dwell_minutesnot asserted.view=week,bucket_minutes=20,mode=volume.b414ad3:used_datesbuilt fromusable;mean_share_percentuses the same set.b414ad3: weighted by ingress, Σ(dwell·in) / Σin.1d34b1f:baseline+mode == "volume"→ 422; rule indocs/api/README.md.b414ad3,is_cycle_data_trusted_async: current unsuperseded verdict, ranked retroactive audit > manual > automatic. README: days with no current verdict or an untrusted one are excluded.New findings
CONTEXT.mdhas no such entry. The selection rules (trusted verdict, up to 8 weeks back, ≥2 days, equal dayparts only) live only in the API README.get_dwell_dayparts_async, equal edges come from each day's ownopen_epoch/close_epoch. If opening hours changed during the 8-week lookback, days are still averaged by position with different cut times — the problem the volume-mode rejection solves.UsualDaypartstill has no label. README should say "equal thirds of each day's open window", or drop source days whose edges differ from the target day's./api/analytics/dwell/daypartswithdate=, but the route parameter isday(analytics_controller.py:30). Passes only because the 422 check runs beforedayis used.bucket_time_labelacross days without a check like the daypart one. Fine for fixed clock buckets; noted for completeness.Other spec points check out: listed dates = averaged dates; holiday/gap/untrusted days skipped; <2 usable days →
null+ reason; #77 item 2 fixed; controllers thin.Merge readiness (this axis): Not yet — one P2 (Usual Weekday Baseline glossary entry in
CONTEXT.md). Everything else is P3 → follow-up issue.Summary: Blocking (P2): Standards — viewer
bucket_minutesunbounded (0→ 500ZeroDivisionError, negatives accepted), plus weighted-dwell / trust-priority tests missing; Spec — no Usual Weekday Baseline glossary entry inCONTEXT.md. Worst per axis: Standards → the 500; Spec → missing glossary term.🤖 Generated with Claude Code