feat(statistics): compare partial periods by daily average above a coverage threshold (#104) #116
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!116
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/statistics-comparison-coverage"
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 #104
Stacked on #115 (config save fix), which this PR needs: the new setting lives in the same config form, and before #115 the form could not round-trip it. Review #115 first; retarget to
masteronce it merges.Summary
Partial weeks and months no longer blank every comparison they touch. Complete periods compare totals; if either side is partial and both cover at least the configured share of their business days, they compare daily averages over the covered days; below that the change is withheld (
PARTIAL_PERIOD). Step 2 of the pre-deck work agreed on 2026-09-25; resolves the problem behind #100.Problem
Production has one full-day outage (Sun 13 Sep 2026). Under the previous rule it hid "vs previous week" for two weeks and "vs previous month" for September and all of October.
Architectural impact
statistics_min_comparison_coverage(REAL, default 0.80 asDEFAULT_MIN_COMPARISON_COVERAGE, validated 0.5–1.0), added by the existing in-place column migration, written byupdate_config_*, editable in the admin config form ("Statistics comparisons").PeriodQuality.coverage(covered / business days; Closed Days excluded per #103).compare_visitors(...)inanalytics_service.pyis the single rule for the summary comparisons and both entrance comparisons (replaces three inline copies).VisitorComparison:basis(TOTAL|DAILY_AVERAGE) andreference_daily_average.reference_visitorsstays the raw reference total.Verification
tests/test_statistics_comparison_coverage.py(5): complete periods →TOTAL; a reference week missing one day (6/7) →DAILY_AVERAGEon the summary and the entrance comparison, with the expected change; raising the threshold to 0.90 through the config API withholds it (PARTIAL_PERIOD); thresholds 0.4 and 1.1 are rejected with422 VALIDATION_ERROR.PARTIAL_PERIOD).pytest: 340 passed. Ruff, pre-commit andscripts/check_docs.pypassed.Checklist
basis; below →PARTIAL_PERIOD.🤖 Generated with Claude Code
Code review — pass 1 (two-axis)
Reviewed the PR's own diff
git diff origin/fix/occupancy-config-save-preserves-calibration...origin/feat/statistics-comparison-coverage(7cf4a0b) against #104. Standards and Spec ran independently and are reported separately. Severity: P1 must fix · P2 fix before merge · P3 minor. Per review policy, pass 1 findings are all fixed on the branch.Standards
Ruff check/format clean; 33 statistics tests pass (throwaway worktree).
Hard violations (a repo convention exists)
app/schemas/occupancy_models.py:~331: bounds are bare literalsge=0.5, le=1.0, while the same file names bounds as constants (OPERATIONAL_GUARDRAIL_MIN/MAX, :309-310). Add named floor/ceiling constants. The same numbers are copied as literals inindex.html:803(min="0.5" max="1"), the label text(0.5–1)anddocs/api/README.md:134— four places to change.app/static/index.html:803value="0.80"andapp/db/database.py:247DEFAULT 0.80repeatDEFAULT_MIN_COMPARISON_COVERAGE. Consistent with the existingtrust_*rows, but still a hard-coded copy.Real bugs and risks
analytics_service.py:122-131: whencurrent_quality is None,now = float(current)is a total; if the reference is partial and above the threshold,beforeis a daily average — different units. The onlyNonecaller today is the day comparison (:375), where a reference day with data always hascovered=1, so it's latent. Makecurrent_qualityrequired, or treatNoneas complete withcovered_daysfrom the reference.analytics_service.py:129-130: division bycovered_daysis only protected bymin_coverage > 0, guaranteed by the Pydantic schema;_min_comparison_coverage(:218) reads the raw DB value unclamped, so a stored 0 raisesZeroDivisionError. Clamp or guardand covered_days.ALTER ... ADD COLUMNwithNOT NULL DEFAULTbackfills.occupancy_repository.py:~363/~450):COALESCE(?, col)keeps the stored value when the key is missing; correct, matchestrust_*.granularity != "day"guard is harmless (current days withcovered=0are rejected at:214; reference days without data return earlier).Baseline smells (judgement calls)
compare_visitors(reference_period, current, reference, current_quality, reference_quality, min_coverage)— (count, quality) pairs travel together; a smallPeriodCount(visitors, quality)removes theNonebranches.:126-129):if current_quality else 1.0andif current_quality else float(current)repeat the sameNonecheck._min_comparison_coverage(:218) is a one-line wrapper aroundcfg.get.basis, now, before = "TOTAL", ...infersbasisasstr, notComparisonBasis.UI
index.html:799-803: new copy ("STATISTICS COMPARISONS", "MIN COVERAGE…") has nodata-i18n(159 uses elsewhere inindex.html); neighbouring admin config labels aren't translated either, so consistent with the form — but hard-coded English with bounds in the label.Spec note
docs/api/README.mdsays the rule covers "a week or month"; the code applies it to days too. Equivalent outcome, but the docs should say days are never partial.Spec
Verdict: no P1/P2. Every decision in #104 is in the code; nothing goes against the spec.
(a) Missing or partial
None.
app/db/database.py:247, named constant and Field inapp/schemas/occupancy_models.py, admin inputindex.html:796.PeriodQuality.coverage(app/schemas/statistics.py:36); the denominator already excludes Closed Days.compare_visitors(analytics_service.py:108-143), using>=, so a period exactly at the threshold is compared.previous,last_year,same_weekday_last_week, and entranceprevious/last_year.(b) Scope creep
reference_daily_average(statistics.py:129) is a contract addition; harmless. For months it repeatsprevious_month_daily_average(analytics_service.py:~418), which still divides by covered days whatever the coverage — two sources for the same number. Note it in the docs or retire one later.(c) Implemented but wrong
Nothing confirmed.
analytics_service.py:258: thegranularity != "day"exemption is gone. A day is covered (1/1) or not (0/1), and a reference day with no data exits earlier asNO_DATA_*, so nothing changes — unless counted-flowhas_dataand cycle-qualityhas_datacan disagree for a day. No test pins the day path under the new rule.covered_days0 has coverage 0 →PARTIAL_PERIOD, no division; a fully closed current period returns early;last_yearomission unchanged (PARTIAL_PERIODstill carriesreference_visitors);usual_weekdaycorrectly out of scope.Tests
The 5 new tests fail without the change (
basisdidn't exist; the old rule gavePARTIAL_PERIODfor 6/7). 33 statistics tests pass in a throwaway worktree. An ad-hoc probe with the current week partial (6/7) vs a complete reference gaveDAILY_AVERAGE, −16.7% on summary and entrances — correct, but not in the suite.Gaps (P3,
tests/test_statistics_comparison_coverage.py):last_yearwithDAILY_AVERAGE.DAILY_AVERAGE).>=.Minor
Spec: "the partial badge shows".
quality.partialstays true; no statistics UI renders it yet, so only the consumer can check it.Summary: Standards 11 (2×P2, 9×P3), worst:
compare_visitorscan compare a total against a daily average whencurrent_qualityis None (latent today), plus bare 0.5/1.0 bounds copied in four places. Spec 8 (all P3), worst: no test with the current side partial or with Closed Days + coverage.🤖 Generated with Claude Code
Pass-1 findings addressed in
0b6df9b(merge of the updated #115) and the following fix commit. Full suite 349 passed; frontend 80/80; ruff and docs check clean.Standards
0.5/1.0boundsMIN_COMPARISON_COVERAGE_FLOOR/_CEILINGinoccupancy_models.py, used by the schema Field, the service clamp and the tests. The HTML input keepsmin/maxattributes (browser validation, same as thetrust_*inputs) but the bounds are gone from the label text.value="0.80"/DEFAULT 0.80copiestrust_*rows (SQL DDL and HTML can't import the constant); noted here.current_quality is Nonecurrent_qualityis now required onstatistics_visitor_comparison_async; the day comparison passes the day's quality._min_comparison_coverageclamps the stored value to the named bounds, so both sides cover ≥ 1 day before dividing.PeriodVisitors(visitors, quality)NamedTuple withper_day();compare_visitors(reference_period, current, reference, min_coverage).Nonechecks_min_comparison_coveragebasisinferred asstrComparisonBasis.occupancy.statisticsComparisonsTitle/minComparisonCoverageLabelin es and en.Spec
reference_daily_averagevsprevious_month_daily_averagelast_yearby daily average; Closed Days + coverage at exactly 0.80 (fails if>=becomes>, mutation-checked); stricter threshold asserted on summary and entrances.🤖 Generated with Claude Code
Code review — pass 2 (two-axis)
Re-reviewed at
ace941e. Each axis verified every pass-1 finding against the code, then reviewed the fix commit(s) for new problems. Verdicts: ✅ fixed · ◐ partial · ❌ not addressed · ↺ regressed · ⊘ declined with documented reason. Per policy, P1/P2 block merge; P3s go to one follow-up issue.Standards
Pass-1 head
7cf4a0b; fix commitace941e. Ruff check/format clean;tests/test_statistics_comparison_coverage.py8/8 and the statistics/occupancy/analytics subset 98/98 (throwaway worktree).Pass-1 findings
ge=0.5, le=1.0boundsMIN_COMPARISON_COVERAGE_FLOOR/CEILING(occupancy_models.py:193-194), used at:335-336, in the service clamp (analytics_service.py:232) and tests; bounds removed from the label. HTMLmin/maxand README0.5–1.0stay literals, liketrust_*.value="0.80"/DEFAULT 0.80copiestrust_*. Accepted.current_quality is Nonecurrent_quality: PeriodQualityrequired (:252); the day path passesquality(:391); noNonebranch left.:227-232). See N1.PeriodVisitors(visitors, quality)withper_day()(:111-119), used at all 3 sites.None_min_comparison_coveragebasis: strbasis: ComparisonBasis(:134).data-i18natindex.html:800,802(label text in a<span>, so the input survives); keys underoccupancyin es and en.New findings
analytics_service.py:137: comment overstates its guarantee. It says min_coverage clamped above zero means both sides cover ≥ 1 day, butPeriodQuality.coveragereturns1.0whenbusiness_days == 0, so a fully closed side withcovered_days=0passes when the other side is partial, andper_day()would divide by zero. Unreachable today (the summary returns early onCLOSED_PERIOD; entrances skip closed cycles soevent_countis 0). Guard inper_day()/compare_visitors, or reword to name the callers' guard.tests/test_statistics_comparison_coverage.py:210: upper out-of-range case1.1is a bare literal; useMIN_COMPARISON_COVERAGE_CEILING + 0.1.:94-96:test_threshold_and_its_bounds_are_namedonly checks the constants are ordered.Merge readiness (this axis): Ready. All pass-1 P2s fixed; no P1/P2. P3s → follow-up issue.
Spec
Own diff only, fix commit
ace941e. 20 statistics tests pass (_comparison_coverage,_summary,_closed_days) in a throwaway worktree; three mutation checks run there.Pass-1 findings
reference_daily_averageduplicatesprevious_month_daily_averagedocs/api/README.md:134); both computeround(reference / covered_days, 1).CLOSED_PERIOD/NO_DATA_*beforecompare_visitors; bothhas_datasources use the sameEXISTSover the same camera filter and bounds (occupancy_repository.py:1260,:1317). And it is pinned: forcing every day comparison toPARTIAL_PERIODfailstests/test_statistics_summary.py:225.test_a_partial_side_above_threshold_compares_daily_averages[current]last_yearwithDAILY_AVERAGEtest_last_year_and_months_use_the_same_ruletest_closed_days_count_towards_coverage_and_the_threshold_is_inclusive(2 Closed Days, 4/5)>=becomes>(mutation).quality.partialstays true; no statistics UI renders it yet.Other checks: forcing the daily-average branch always fails
test_complete_periods_compare_totals(TOTALpinned). All five call sites route throughcompare_visitors. Spec decisions re-checked against the fix commit: "default 0.80, validated to 0.5–1.0" (named bounds + clamp), "below the threshold:change_percent: null, reasonPARTIAL_PERIOD", "Applies to the summary comparisons and the entrance comparisons" — all met.New findings
docs/api/README.md:134: "Each comparison states itsbasis" —basisis null on withheld comparisons (PARTIAL_PERIOD,NO_DATA_*,CLOSED_PERIOD) and onusual_weekday, which does compute achange_percent. Docs precision only; suggest "each calculated comparison exceptusual_weekday".Merge readiness (this axis): Ready. No P1/P2; P3s → follow-up issue.
Summary: Standards: no P1/P2; 4 P3 (worst:
per_day()comment overstates its zero-division guarantee). Spec: no P1/P2; 2 P3 (worst: docs say every comparison states itsbasis).🤖 Generated with Claude Code
Pass-2 blockers fixed; remaining P3s filed as #120. Combined master + #115 + #116: pytest 351 passed, 1 skipped; frontend 80/80. Merging.
🤖 Generated with Claude Code