fix(occupancy): business cycles in facility time (#27) #69
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!69
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/facility-time-cycle-boundaries"
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?
Closes #27
Implemented and ready for review.
Problem
Every business-cycle boundary is computed in the server's timezone:
time.localtime+time.mktimewith a copiedtm_isdst, inget_today_midnight_epoch(occupancy_service.py:150),get_business_day_epoch_bounds(:157),get_completed_business_cycle_bounds(:200),get_active_schedule_info_async(:230,:274,:331), and in SQL viaDATE(…, 'unixepoch', 'localtime')(occupancy_repository.py:1543,database.py:433).settings.facility_utc_offset_minutesexists, but it is only broadcast to clients for display.If the server runs UTC and the facility runs UTC−4, the cycle reset and the nocturnal calibration window fire four hours early, while the mall is still emptying. That feeds a spurious residual into
k, which rescales every calibrated figure. Separately, the code hardcodes+ 86400.0per cycle (:191,:193,:225,:1273,:1353,:1438), which is wrong on a DST-observing deployment.Current production impact: none. Checked read-only on 2026-09-23: the production server's local zone is Venezuela time (UTC−4, no DST), and
facility_utc_offset_minutes = -240, so server time equals facility time. This is a latent bug for any deployment whose server clock differs from the facility's.Approach (from the issue's suggested fix; not yet triaged)
facility_now()/facility_cycle_bounds(), that owns all cycle math and applies the facility zone.datetime+ZoneInfoarithmetic instead ofmktimewith a copiedtm_isdst.'localtime'modifiers with epoch ranges computed by the seam.Implementation notes
FACILITY_TIMEZONE selects an IANA zone, with the configured fixed UTC offset as fallback. Cycle boundaries use the facility clock, including DST transitions. Existing stored cycle dates are left intact; this deployment's server and facility clocks already matched.
Acceptance criteria
time.mktime/'localtime'left in cycle math.+ 86400.0for cycle length (or an explicit, documented fixed-length decision).node --test).Sequencing across the
[data-veracity]draftsDeclared together on 2026-09-23; each one is triaged, then reviewed, then implemented, in this order:
FLAG_INGESTION_GAP(in #68) and on #27's seam forcycle_date.Review the PRs in this order; no PR has been merged into master.
🤖 Generated with Claude Code
Implementation decisions
Recorded after review round 2, which flagged these as scope creep:
04dddc0): the schedule for an instant is its business cycle's day.WIP: fix(occupancy): business cycles in facility time (#27)to fix(occupancy): business cycles in facility time (#27)Implementation is pushed at
54bc51e. This branch includes the PR #68 ingestion commits because the facility-time code is tested against that behavior. Review #68 before #69. No PR has been merged intomaster.Standards
Documented Standard Violations (Hard)
app/db/occupancy_repository.py:21— Inverted Architectural Layer Dependency:from app.services.facility_time import facility_cycle_boundsdocs/standards/code-standards.md§1.1 &AGENTS.md§1 (Layer Separation & Boundaries: Repositories).app/db/). Importing fromapp/services/inverts the layer hierarchy (Controllers -> Services -> Repositories). Iffacility_timeis a domain-agnostic time utility, placing it inapp/services/introduces an upward architectural coupling from persistence to service layer.app/main.py:38— Service Encapsulation Bypass:reset_time = (await occupancy_manager.repo.get_config_async()).get("daily_reset_time", "04:00")docs/standards/code-standards.md§1.1 &AGENTS.md§1 (Services)..repoto read configuration instead of querying a high-level service method.Baseline Smells (Judgement Calls)
app/main.py:38): Navigatingoccupancy_manager.repo.get_config_async()violates the Law of Demeter.app/db/occupancy_repository.py:1332, 1356&app/services/occupancy_service.py:192):midnight_epochis retained for variables holding cycle reset epoch (04:00), not midnight (00:00). Similarly,stis kept fordatetimeinstances returned byfacility_now().app/services/facility_time.py:29-54): Returning raw positional 3-tuples (tuple[float, float, str]) leads to index-based unpacking (facility_cycle_bounds(...)[2]). Floating magic epsilon offsets (+ 0.001,- 0.001,- 900.0) are scattered across caller files rather than encapsulated in interval objects.app/services/occupancy_service.py:188-195):get_completed_business_cycle_boundsacts as an unadorned pass-through tocompleted_facility_cycle_bounds.app/services/occupancy_service.py:210, 241): Overnight interval extension (if close_epoch <= open_epoch: close_epoch = facility_at(st.date() + timedelta(days=1), close_time).timestamp()) is duplicated across holiday and standard schedule branches.Spec
Missing or Partial Requirements
app/db/database.py, the legacy backfill query was removed rather than replaced. Inapp/db/occupancy_repository.py, rather than filtering SQL by epoch ranges from the seam, missing cycle dates are fetched in bulk into Python and updated one by one in anexecutemanyloop.app/main.pyaddslogger.infobut no assertion or validation, and logs the zone name (America/Caracas) rather than the numeric UTC offset when configured.tests/test_facility_time.pyverifies cycle bounds and store schedule hours, but omits testing whether the nocturnal quiet window (03:30–04:30) lands at facility time.Scope Creep (Unasked Behaviour)
app/services/occupancy_service.py,get_active_schedule_info_asyncadds overnight rollover logic to the holiday schedule branch (if close_epoch <= open_epoch: ...), modifying holiday behavior beyond the cycle boundary mandate.app/db/occupancy_repository.py,reset_daily_counts_sync/asyncaltered the deletion threshold from calendar midnight (>= midnight_epoch) to business cycle start (>= 04:00).Incorrect Implementations
completed_facility_cycle_bounds()boundary error: Infacility_time.py:46, the function subtracts one calendar day (facility_now(epoch).date() - timedelta(days=1)) without checkingreset_time. If called between 00:00 and 04:00 (e.g. at 02:00), it returns the active cycle instead of the completed one. Ifreset_timeis configured to00:00, it shifts an extra 24h back.quarantine_and_deduplicate_calibration_logs_async, missing cycle dates are backfilled withfacility_cycle_bounds(float(row["timestamp_epoch"]), reset_time)[2]. Calibration runs timestamped after reset (e.g. 04:15 with a 04:00 reset) are attributed to the newly started cycle rather than the completed cycle being calibrated.OccupancyManager.get_cycle_minute(), modulo arithmetic((st.hour - reset_h) % 24) * 60is retained instead of calculating elapsed seconds fromcycle_start, failing on 23h and 25h DST transitions.Summary: 7 standards findings (worst: repository importing from
app/services/facility_timeinverting layer boundaries); 8 spec findings (worst:completed_facility_cycle_bounds()miscalculating completed cycle between midnight and 04:00, and quiet-window log misattribution).Addressed the review in
2ff8746and1463e8c(with the #68 fixes carried into this branch).The review's broader cleanup suggestions for older cycle call sites and variable names can be handled separately. The overnight/holiday behavior and reset semantics follow the facility-cycle model documented for this PR. No merge was performed.
Review Follow-up & Verification of Previous Findings
Previous Review Status
app/db/occupancy_repository.py: Inverted architectural layer dependency resolved.facility_time.pymoved toapp/facility_time.py, imported cleanly by persistence and service tiers (docs/standards/code-standards.md §1.1).app/main.py: Service encapsulation bypass resolved. Startup callsawait occupancy_manager.get_daily_reset_time_async()instead of.repo.get_config_async(). Message chain removed.completed_facility_cycle_bounds()boundary error: Resolved; usescalibration_cycle_bounds()for quiet-window reset straddling.calibration_cycle_bounds()to correctly attribute runs around 04:15.(epoch - cycle_start) // 60).RuntimeError; UTC offset and facility zone logged on boot.midnight_epochvariable name retained for 04:00 reset boundary.(start, end, label)return and manual epsilon offsets retained.get_business_day_epoch_boundsretained as convenience method.Items Missed by Previous Review
03:30–04:30, straddling reset at04:00. At 03:30,check_and_run_auto_calibration_asyncevaluatesin_windowas true and runscalibrate_baseline_offset_async(use_completed_cycle=True). This selects cycleT-1, queriescycle_end(03:59:59.999, in the future), and sets_last_auto_calib_date, permanently skipping post-reset evaluation at 04:15.occupancy_service.py:1148andanalytics_service.py:312perform duplicate minute arithmetic (st.hour * 60 + st.minute) rather than epoch intervals derived fromfacility_time.py.Standards
Documented Standard Violations (Hard)
Baseline Smells (Judgement Calls)
app/facility_time.py:31, 38, 48, 56):Returning raw 3-tuples forces callers across
occupancy_repository.pyandanalytics_service.pyto index unpack (bounds[2]), and magic epsilon offsets (+ 0.001,- 0.001) are manually applied at call sites rather than encapsulated in an Interval value object.app/db/occupancy_repository.py:1361, 1385):midnight_epochis retained for variables storing the business cycle reset boundary (04:00 AM), which is not midnight.app/services/occupancy_service.py:180, 192):get_business_day_epoch_boundsandget_completed_business_cycle_boundssimply forward tofacility_cycle_boundsandcompleted_facility_cycle_bounds.Spec
Missing or Partial Requirements
occupancy_service.py:1148andanalytics_service.py:312bypassesfacility_time.pywith ad-hochour * 60 + minutechecks.tests/test_facility_time.pyasserts boundary helper outputs, but omits testing quiet-window background daemon execution under UTC server time.Scope Creep (Unasked Behaviour)
get_active_schedule_info_asyncadds overnight rollover logic to holiday branches (occupancy_service.py:210, 241).04:00).Incorrect Implementations
_last_auto_calib_date, and prevents the proper post-reset run at 04:15.Summary: 3 standards findings (worst: primitive obsession and data clumps with raw 3-tuples and scattered floating epsilon offsets); 5 spec findings (worst: quiet window auto-calibration triggering at 03:30 evaluating cycle before reset has completed and skipping post-reset run).
Review round 2 addressed —
30cb43b,04dddc0(plus #68's fixes merged in)Spec
facility_window_containing()/seconds_until_window_start()helpers inapp/facility_time.py(midnight wrap handled), nothour * 60 + minute.TZ=UTC, facility UTC−4: no run at 03:35, a run at 04:15 facility time, no run at 04:15 server time (00:15 facility). A mutation check confirms the straddle test fails without the fix.get_active_schedule_info_asyncnow resolves the business day (04dddc0). Test:test_schedule_after_midnight_uses_the_business_day_before_reset.Standards
CycleBoundsnamed tuple (still unpackable) withnext_start;CYCLE_END_EPSILONreplaces bare± 0.001.midnight_epoch: renamedcycle_start_epoch.get_business_day_epoch_bounds/get_completed_business_cycle_boundsas thin service methods. They're the service-layer API that callers and tests use.Scope-creep findings (holiday overnight close, counter-reset threshold at the business-cycle reset): recorded as implementation decisions in the description.
Verification: pytest 259 passed / 1 skipped, ruff clean, pre-commit passed.
Stacking: #68 → #69 → #70 → #71. Each branch now merges the one below it, so fixes propagate by merge instead of by cherry-pick. Merge in that order.
Code review — round 3 (pre-merge)
Reviewed this PR's own increment in the stack (#68 → #69 → #70 → #71) with two independent passes: Standards (
docs/standards/code-standards.md,AGENTS.md,CONTEXT-FORMAT.md/ADR-FORMAT.md, a code-smell baseline) and Spec (the PR description and its implementation decisions, all previous rounds, and the originating issues). The Spec pass re-ran the suites in a throwaway worktree.Policy for this round: P1 is fixed before merge; P2 is fixed or explicitly accepted; P3 goes to follow-up issues (inline production values → #74, language-specific text → #73).
Standards
Round-2 fixes hold. No hard violations.
get_business_day_epoch_bounds/get_completed_business_cycle_boundswrappers were kept citing AGENTS.md's "controllers delegate to services", but every caller is a service or a test. Their only real use is an argument-order shim for old tests.tuplereturns but returnCycleBounds; a new thinget_calibration_cycle_bounds;facility_window_containingreturns a raw pair read aswindow[1];today_strnow holds the business day, so the name misleads;date.fromisoformat(label)is duplicated (CycleBoundscould carry the date);facility_atre-parses"HH:MM"alongsideparse_time_str."04:00"default arguments acrossfacility_time.pyand the repository; the"AUTOMATIC_NOCTURNAL"string compare.Spec
pytest 259, node 67. A minute-by-minute daemon simulation over 3 days (server on UTC, facility on UTC−4, the in-memory guard and the audit-log dedup modelled) found no skipped and no doubled calibration for 03:30–04:30 around 04:00, a
00:00reset, windows after the reset, or restarts inside the window.calibration_cycle_boundspicks the cycle by calendar day. A 21:00–22:00 window calibrates the cycle that ended 17 h earlier. A 23:30–00:30 window idles until midnight, then calibrates the cycle still in progress. Production (03:30–04:30 around 04:00) is unaffected, and the code behaved the same before this PR.00:00default, a venue open past midnight is still reported closed after midnight; a pre-reset run logs a futurelast_calibrated_at;docs/guides/statistical_occupancy_models.md:49still saysstart + 86400; CI still pinsTZ=America/Caracas, and 3mktimetests fail underTZ=UTC, so CI can't catch a server/facility mismatch; display strings still use server time.tzdatain requirements, stored history): sound.Standards: 6 findings, worst P3. Spec: 7 findings, worst P2 (quiet windows away from the reset).
Resolution (maintainer, 2026-09-24): the P2 is accepted. Production is unaffected, the behaviour predates this PR, and the requirement that the window straddle or follow the reset is being documented now. A proper fix goes to the follow-up issue with the P3s.
Review round 3 addressed —
884aed8(plus #68's round-3 fixes merged in)facility_time.calibration_cycle_bounds, thecalibration_window_startfield description anddocs/architecture/admin-data-visualization.md. A proper fix or config validation is tracked in #76 with the P3s.TZpin and the 3mktimetests that fail under UTC); inline values → #74.Verification: pytest 263 passed / 1 skipped, CI green on
884aed8.