refactor(statistics): baseline and query follow-ups #167
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!167
Loading…
Reference in a new issue
No description provided.
Delete branch "refactor/statistics-baseline-followups"
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
Batch the daily statistics series' counted flow, calibration logs, schedule context, and open-window counts with unnested joins for indexed per-cycle evaluation. Reuse configuration in baseline sample reads, guard hourly baselines against differing bucket labels while averaging duplicate clock labels positionally by index, validate baseline bucket size before aggregation, type repository batch inputs with
CycleWindowand typed returns withRangeCountsandCalibrationLogRecord, narrow candidate sample aggregation for hourly and dayparts baselines, and cover edge cases (verdict priority, sparse baselines, non-trivial shares, 62-day series query count). Multi-day Riemann step calculation batching is deferred to linked issue #203. The issue-by-issue evidence and remaining work are in docs/audit/statistics-baseline-followups-pr-plan.md.Architectural impact
The occupancy repository now offers batched read contracts for cycle flow, calibration logs, and named windows accepting
Sequence[CycleWindow]and returning typed models (CountedDayFlow,RangeCounts,CalibrationLogRecord). The analytics service combines those reads, narrows baseline candidate evaluation to direct bucket flow (_get_sample_hourly_in_counts_async) and dayparts (_compute_dayparts_for_day_async) bypassing full responses and redundant cycle quality queries, and preserves existing response shapes, Closed Day rules, and ADR 0006 multiplier behavior. No schema migration or new dependency is included.Verification
rtk pytest: 526 passed.rtk ruff check .: passed.rtk ruff format --check .: passed.python3 scripts/check_docs.py: passed.Checklist
CycleWindow,RangeCounts, andCalibrationLogRecord.This PR addresses #96 and #97 and closes #124 when merged. Keep the ready-for-agent role while implementation remains.
Review protocol
This PR addresses review follow-up issues, so it follows the three-pass path in docs/standards/git-and-workflow.md. First and second passes address every P1/P2/P3 finding; the third addresses P1/P2 and files linked follow-up issues for any deferred P3.
WIP: refactor(statistics): baseline and query follow-upsto refactor(statistics): baseline and query follow-upsCode review, pass 1 (
origin/master...c42844c, spec #96, #97, #124)Result: 1 P1, 2 P2s and 13 P3s. This PR addresses follow-up issues, so it takes the three-pass path: every finding on passes 1 and 2 gets fixed.
Verification:
Standards
P1
occupancy_repository.py:1644get_counted_cycle_flow_range_async).LEFT JOIN (people_counting_events e … ) ON e.timestamp_epoch >= p.start_epoch …has no outer time bound. SQLite materializes the whole events table and scans it once per cycle (MATERIALIZE (join-3), thenSCAN … LEFT-JOIN).get_counts_in_ranges_async(:1712) already boundsmin_start/max_endinside the join (0.005 s, indexed). Apply the same bound here.test_daily_series_62_days_query_countcounts connections, not query cost, so it can't catch this. Add a plan or bound assertion.P2
2. Duplicated Code against ADR 0006 (
analytics_service.py:973-982). The daily series copies the trust rule from_trusted_cycle_log_async(:423), so one calibration-honesty rule now has two copies. Extract a pure_trusted_multiplier(log, default)and use it in both.3. Duplicated Code (judgement call,
analytics_service.py:948-963).close_epoch <= open_epochrollover, is a hand copy ofOccupancyService._schedule_epochs.ScheduleCalendaror in facility_time.P3
4. DST fall-back (
analytics_service.py:1284).dict(zip(labels, counts))keeps only the last bucket when two share a clock label, where the old code appended both. Matches Spec #97's missing bucket-label guard.5.
_get_sample_hourly_in_counts_async(1254-1284) repeats the main path's bucket loop. Have the main path use it.6. Speculative Generality. The new
resetandreset_time_strparams onget_dwell_dayparts_async/get_hourly_timeseries_async(1148, 1294) have no callers, and they let a reset disagree withcfg. Delete them.7.
_daily_reset_time = configured_reset_time(:74) is a bare alias. Also,OccupancyService.get_daily_reset_time_asyncstill uses.get(..., "04:00"), so the defaults diverge.8.
validate_closed_statistics_day_async(:379) now also returns config; considerclosed_day_config_async.9.
TimeRangeTarget(occupancy_models.py:46) has the same shape asCycleQualityTarget, and the flow and log methods take a "quality" type. Use oneCycleWindowtype for both.10. Redundant guard (:1001):
not is_closed and day_str in open_window_by_day.Checked and equivalent:
id DESCtiebreak is an improvement;.get;DwellDayserialization;Spec
Checklist:
active_business_day;P3
11. #97, missing: "Hourly baseline has no guard for differing bucket labels across source days." It is neither addressed nor deferred (see #4).
12. #97, partial: "extract active_business_day(reset)".
analytics_service.py:886still usesfacility_cycle_bounds(span[0], reset), yet the plan doc marks the item Complete.13. #96, partial: "A 62-day range still costs a few hundred queries." Peak and average are still read per day. The deferral is in the plan doc but has no linked follow-up issue.
14. #96, partial: "Use a NamedTuple/TypedDict."
-
get_counts_in_ranges_asyncreturnsdict[str, tuple[int, int]].-
get_calibration_logs_for_cycles_asyncreturnsdict[str, dict[str, Any]].- Both take bare
tuple[str, float, float]as well.15. The plan doc is wrong in places.
- It credits a
handle_controller_errors()helper that doesn't exist; the helper isdomain_errors().- Lines 30, 33, 35, 36 and 51-53 present items already done on master as this PR's work.
- It omits the #97 opening-hours item that moved to #105/#106.
16. Trust predicate duplicated (same as Standards P2-2).
Scope creep: small and justified: the holiday-hours fallback, the
id DESCtiebreak, the skipped config reads, andDwellDaytyping.Summary
🤖 Generated with Claude Code
Pass 1 fixes (
eda7191)All 16 findings (1 P1, 2 P2s, 13 P3s) from pass 1 review
r13have been resolved:Standards
min_startandmax_endbounds to thepeople_counting_eventssubquery join inget_counted_cycle_flow_range_async(app/db/occupancy_repository.py). Updatedtest_daily_series_62_days_query_countwith anEXPLAIN QUERY PLANassertion verifying indexed search over whole-table scan._is_trusted_log(log)and pure_trusted_multiplier(log, default)inapp/services/analytics_service.py. Reused in_trusted_cycle_log_async,_trusted_cycle_multiplier_async, and the daily series loop.schedule_epochs(day, open_time, close_time)inapp/facility_time.pyandScheduleCalendar.schedule_epochs(day). Reused inOccupancyService._schedule_epochsandget_statistics_daily_series_async.(label, in_count)tuples instead of a dict, preventing key collisions when multiple buckets share a clock label on DST fall-back._compute_hourly_buckets_for_day_asyncinapp/services/analytics_service.pyto build and populate flow buckets once; reused by bothget_hourly_timeseries_asyncand_get_sample_hourly_in_counts_async.resetfromget_dwell_dayparts_asyncandreset_time_strfromget_hourly_timeseries_async. Both resolve internally viaconfigured_reset_time(cfg)._daily_reset_timealias inanalytics_service.pyand standardized onconfigured_reset_time(cfg)across all call sites, includingOccupancyService.get_daily_reset_time_async.validate_closed_statistics_day_asynctoclosed_day_config_asyncinanalytics_service.py(preserving backward compatibility alias) and updatedstatistics_controller.py.CycleQualityTargetandTimeRangeTargetintoCycleWindowinapp/schemas/occupancy_models.py(preserving aliases).not is_closed andinif day_str in open_window_by_day:inget_statistics_daily_series_async.Spec
if [label for label, _ in sample] != target_labels: continuewithusable_datesandINSUFFICIENT_USUAL_WEEKDAYScheck inget_hourly_timeseries_async. Added tests for mismatched bucket labels and DST fall-back averaging intests/test_statistics_baselines.py.span[0]civil date extraction inlist_statistics_periods_asynctofirst_day = active_business_day(span[0], reset).perf(statistics): batch per-cycle peak and average occupancy Riemann integration) labeledready-for-agent.RangeCounts(NamedTuple)returned byget_counts_in_ranges_async, andCalibrationLogRecord(TypedDict)returned byget_calibration_logs_for_cycles_async.docs/audit/statistics-baseline-followups-pr-plan.mdto creditdomain_errors(), clarify items that were already merged onmasterprior to branch, document the opening-hours item moving to #105/#106, and link follow-up #203._trusted_multiplier.All 526 tests passing, ruff lint/format clean, check_docs clean. Ready for pass 2 review.
Code review, pass 2 (
origin/master...eda7191, spec #96, #97, #124)Result: 1 P1, 1 P2 and 8 P3s. This PR addresses follow-up issues, so it takes the three-pass path: fix every finding below, push, and request a third pass. Several Standards and Spec findings are the same issue: S5 with Sp1, S3 with Sp3, and the typed-argument items.
Verification:
test_statistics_baselines.pyandtest_statistics_periods.py: 25 passed.scratchpad/probe167p2.py: SQLite 3.51.2, the repo's real schema and 4 indexes, 200k events, 20 cameras (1 excluded), 62 cycles, best of 3.c42844c(unbounded)eda7191(bounded)Standards
Pass-1 fixes:
active_business_dayandget_daily_reset_time_asynccheck out.P1
occupancy_repository.py:1645-1670).MATERIALIZE (join-63) … SCAN p | SCAN (join-63) LEFT-JOIN. The bound only shrinks the materialized set, and every cycle still scans all events in range, so cost is cycles × events in range.eis searched per cycle: The plan becomesSCAN p | SEARCH e USING INDEX … (camera_index_code=? AND timestamp_epoch>? AND timestamp_epoch<?), the totals are identical, and the speed matches per-day.tests/test_statistics_periods.py:~552-558)."SEARCH e USING"also appears in the unbounded plan. Assert that the plan has noMATERIALIZEand noSCAN (join-.get_counts_in_ranges_async(:1720) has the same shape and is also new in this PR. Pass 1 wrongly judged it fine: 62 open windows take 0.62 s and 2.73 s. Apply the same rewrite and the same plan assertion.P2
2.
CalibrationLogRecord(occupancy_models.py:62-78) doesn't match theoccupancy_calibration_logsschema (database.py:620-680).previous_exit_multiplier,baseline_offsetandpatrol_guard_count. The last two are config keys.target_guard_count, whichanalytics_service.py:1369reads.is_trusted/is_currentasbool, but SQLite returns int.total=Falsepluscasthides all of this. Derive the fields from the schema.P3
3. Speculative Generality: unused "backward compatibility" aliases.
TimeRangeTarget = CycleWindow(occupancy_models.py:52) andvalidate_closed_statistics_day_async = closed_day_config_async(analytics_service.py:408) have no callers.CycleQualityTargetis still used atanalytics_service.py:35,374,643,825,845,848and in the tests, so one file uses two names for one type.CycleWindow.cycle_date(:45-47) is an alias property forwindow_id. Keep one name.analytics_service.py:1513-1526)._trusted_multiplier(:83-87) needs# type: ignore[index]only because narrowing goes through_is_trusted_log. Return the multiplier or None from one function.Spec
Pass-1 fix claims (c3431):
active_business_day(span[0], reset). True.ready-for-agent, cites #96/#167, and is linked from the plan doc. True.domain_errors(), "landed on master prior to branch", the #105/#106 move, #203). True._is_trusted_log/_trusted_multiplierused at all three sites. True.P3
CycleWindow | tuple[str, float, float](occupancy_repository.py:1646, 1684, 1721, 1789). Only the returns gainedRangeCounts/CalibrationLogRecord. AcceptCycleWindowonly.CycleWindowunification is half done (same as Standards P3-3).TimeRangeTarget/CycleQualityTargetas the contracts and says "465 passed"; the fix reply says 526.CycleWindow,RangeCounts,CalibrationLogRecord, the bucket-label guard, or the #203 deferral.Checklist:
Summary
🤖 Generated with Claude Code
Pass 2 fixes (
38d1283)All 10 findings (1 P1, 1 P2, 8 P3s) from pass 2 review r22 have been resolved:
Standards
get_counted_cycle_flow_range_asyncandget_counts_in_ranges_asyncinapp/db/occupancy_repository.pyby removing the parenthesized subquery join and joiningpeople_counting_eventsdirectly per cycle/window withcamera_index_code IN (SELECT ...).tests/test_statistics_periods.pyto verify thatEXPLAIN QUERY PLANcontains noMATERIALIZE, noSCAN (join-, and uses index search for both flow and open window queries.CalibrationLogRecordinapp/schemas/occupancy_models.pydirectly fromoccupancy_calibration_logs(app/db/database.py).previous_exit_multiplier,baseline_offset,patrol_guard_count), added real columns includingtarget_guard_count, typedis_trustedandis_currentasintmatching SQLite return types, and removedtotal=False.TimeRangeTarget = CycleWindowandCycleQualityTarget = CycleWindowfromoccupancy_models.py.validate_closed_statistics_day_asyncfromanalytics_service.py.CycleWindowacross all call sites inanalytics_service.pyand tests.cycle_datealias property fromCycleWindow.get_hourly_timeseries_async(app/services/analytics_service.py), averaged usable samples by index position instead of grouping by clock label.test_hourly_baseline_appends_duplicate_clock_labels_on_dst_fallbackintests/test_statistics_baselines.pyto assert positional averages (10.0 and 20.0)._trusted_cycle_multiplier(log)returningfloat | None, allowing clean narrowing in_is_trusted_logand_trusted_multiplierwithout# type: ignore[index].Spec
cycles/rangesparameter in all four batch repository methods (get_counted_cycle_flow_range_async,get_calibration_logs_for_cycles_async,get_counts_in_ranges_async,get_counted_cycle_quality_range_async) toSequence[CycleWindow]only.tests/test_occupancy_config_api.pyandtests/test_statistics_periods.pyto passCycleWindow.CycleWindow,RangeCounts,CalibrationLogRecord, positional baseline averaging, 526 passed tests, and linked follow-up issue #203.All 526 tests passing, ruff lint/format clean, check_docs clean. Ready for pass 3 review.
Code review, pass 3 (
origin/master...38d1283, spec #96, #97, #124)Result: no P1s, no P2s and 7 P3s. This is the third pass of a follow-up PR, so the P3s are deferred to the linked follow-up issue #219
#167 and the PR merges.
Verification:
test_statistics_periods,test_statistics_baselinesandtest_occupancy_config_api: 35 passed.scratchpad/probe167p3*.pyandspec167p3.py: SQLite 3.51.2, the real schema and indexes, 200k events, 20 cameras, 62 cycles. The table shows the minimum of 15 interleaved runs, in ms:eda7191(pass 2)Standards
Pass-2 fixes:
get_counted_cycle_flow_range_asyncandget_counts_in_ranges_asyncnow plan asSCAN p | SEARCH e USING INDEX idx_counting_events_cam_range (camera_index_code=? AND timestamp_epoch>? AND timestamp_epoch<?) LEFT-JOIN, with noMATERIALIZEand noSCAN (join-.CalibrationLogRecord: fixed. All 26 columns are present with matching types, andis_trusted/is_currentareint.CycleWindowonly.statistics_baseline_min_samplesis at least 1._trusted_cycle_multipliernarrows without atype: ignore.P3 (deferred to #219
#167):
_trusted_cycle_multiplierand the method_trusted_cycle_multiplier_asynchave nearly the same name.Mapping[str, Any], notCalibrationLogRecord.CalibrationLogRecordomitsrank_in_cycle, and the nullability ofis_trusteddoesn't match the schema.Spec
Fix claims (c3550): all true.
Sequence[CycleWindow]only.CycleWindow,RangeCounts,CalibrationLogRecord, positional averaging, 526 passed, and #203.Checklist:
P3 (deferred to #219
#167):
6. The test name
test_hourly_baseline_appends_duplicate_clock_labels_on_dst_fallbackdescribes the old behaviour.7. The PR addresses all of #97 but doesn't close it. I'm closing #97 by hand after the merge.
Summary
Merging.
🤖 Generated with Claude Code