mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""Weekly and monthly average intake, and how that average met the budget.
|
|
|
|
PERIOD AVERAGE SPEC (keep in sync with
|
|
``app/lib/services/average_service.dart``):
|
|
|
|
* the average is over **logged days only** -- ``sum(kcal) / logged_days``. A
|
|
day you forgot to log is not a zero-calorie day, and counting it as one
|
|
would make every gap in the log read as "well under budget", which is the
|
|
exact opposite of the truth. ``logged_days`` / ``elapsed_days`` travel with
|
|
the average so a caller can show how much of the period it actually covers.
|
|
* the yardstick is the **mean of the per-day budgets over those same logged
|
|
days**, resolved through a
|
|
:class:`~diet_guard._budget_history.BudgetSchedule` -- never today's budget.
|
|
Comparing a past week's intake against a budget you lowered yesterday would
|
|
retroactively reclassify it, which is precisely what
|
|
:mod:`diet_guard._budget_history` exists to prevent.
|
|
* bands, reusing the calendar's own over-budget boundary
|
|
(:data:`~diet_guard._daystatus.OVER_BUDGET_YELLOW_CEILING`) so a period and
|
|
its days can never disagree about what "over" means:
|
|
|
|
- ``under``: ``avg_kcal <= avg_budget``
|
|
- ``slightly_over``: ``avg_budget < avg_kcal <= avg_budget * ceiling``
|
|
- ``very_over``: ``avg_kcal > avg_budget * ceiling``
|
|
|
|
* **today is excluded.** A period ending at "now" mixes complete days with
|
|
one that is three hours old, and a half-logged today drags the mean down far
|
|
enough to flip the band. Every period therefore ends at
|
|
:func:`last_complete_day` -- yesterday -- so "this week" means "this week so
|
|
far, in finished days". A period with no finished days yet (Monday, or the
|
|
1st) reports ``elapsed_days == 0`` and a ``None`` average rather than a
|
|
flattering fake one.
|
|
* weeks are **ISO weeks, Monday through Sunday**; months are calendar months.
|
|
|
|
Every function here is a pure function of its explicit ``log`` / ``schedule``
|
|
/ ``today`` arguments and never reaches into on-disk state, so the band
|
|
boundaries and the period edges are testable with synthetic data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from diet_guard._averages import PeriodAverage, period_average
|
|
from diet_guard._averages_periods import (
|
|
_resolve_today,
|
|
_shift_months,
|
|
last_complete_day,
|
|
month_bounds,
|
|
week_bounds,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from diet_guard._budget_history import BudgetSchedule
|
|
from diet_guard._state import DayLog
|
|
|
|
_DAYS_PER_WEEK = 7
|
|
_MONTHS_PER_YEAR = 12
|
|
|
|
|
|
def _capped(
|
|
log: DayLog,
|
|
schedule: BudgetSchedule,
|
|
bounds: tuple[date, date],
|
|
ref: date,
|
|
) -> PeriodAverage:
|
|
"""Average ``bounds``, truncated so it never includes ``ref`` itself.
|
|
|
|
The single place the "today is excluded" rule is applied, so a caller
|
|
cannot construct a period that half-counts an unfinished day.
|
|
"""
|
|
start, end = bounds
|
|
return period_average(
|
|
log,
|
|
schedule=schedule,
|
|
start=start,
|
|
end=min(end, last_complete_day(ref)),
|
|
)
|
|
|
|
|
|
def weekly_average(
|
|
log: DayLog,
|
|
*,
|
|
schedule: BudgetSchedule,
|
|
weeks_ago: int = 0,
|
|
today: date | None = None,
|
|
) -> PeriodAverage:
|
|
"""Return the average for an ISO week, ``weeks_ago`` weeks back.
|
|
|
|
Args:
|
|
log: A filtered log (see :func:`period_average`).
|
|
schedule: Resolves each day's budget.
|
|
weeks_ago: 0 for the current week (through yesterday), 1 for the
|
|
previous complete week, and so on.
|
|
today: The reference "today"; defaults to the real current date.
|
|
|
|
Returns:
|
|
That week's :class:`PeriodAverage`.
|
|
"""
|
|
ref = _resolve_today(today)
|
|
anchor = ref - timedelta(weeks=weeks_ago)
|
|
return _capped(log, schedule, week_bounds(anchor), ref)
|
|
|
|
|
|
def monthly_average(
|
|
log: DayLog,
|
|
*,
|
|
schedule: BudgetSchedule,
|
|
months_ago: int = 0,
|
|
today: date | None = None,
|
|
) -> PeriodAverage:
|
|
"""Return the average for a calendar month, ``months_ago`` months back.
|
|
|
|
Args:
|
|
log: A filtered log (see :func:`period_average`).
|
|
schedule: Resolves each day's budget.
|
|
months_ago: 0 for the current month (through yesterday), 1 for the
|
|
previous complete month, and so on.
|
|
today: The reference "today"; defaults to the real current date.
|
|
|
|
Returns:
|
|
That month's :class:`PeriodAverage`.
|
|
"""
|
|
ref = _resolve_today(today)
|
|
return _capped(log, schedule, month_bounds(_shift_months(ref, months_ago)), ref)
|
|
|
|
|
|
def recent_weeks(
|
|
log: DayLog,
|
|
*,
|
|
schedule: BudgetSchedule,
|
|
count: int,
|
|
today: date | None = None,
|
|
) -> tuple[PeriodAverage, ...]:
|
|
"""Return the last ``count`` weekly averages, most recent first."""
|
|
ref = _resolve_today(today)
|
|
return tuple(
|
|
weekly_average(log, schedule=schedule, weeks_ago=back, today=ref)
|
|
for back in range(count)
|
|
)
|
|
|
|
|
|
def recent_months(
|
|
log: DayLog,
|
|
*,
|
|
schedule: BudgetSchedule,
|
|
count: int,
|
|
today: date | None = None,
|
|
) -> tuple[PeriodAverage, ...]:
|
|
"""Return the last ``count`` monthly averages, most recent first."""
|
|
ref = _resolve_today(today)
|
|
return tuple(
|
|
monthly_average(log, schedule=schedule, months_ago=back, today=ref)
|
|
for back in range(count)
|
|
)
|