Fix range_days undercounting for the "all time" battery range
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 18s

(end - start).days truncates instead of rounding, so "all time" (which
starts at the earliest reading's exact timestamp, rarely local midnight)
could report fewer range_days than days_with_data -- reads backwards
("5 of 4 days have data"). Round the duration up instead; day-aligned
presets and custom ranges are unaffected since they're already exact
multiples of 24h.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-13 11:13:19 +10:00
parent c30165c153
commit 591da31e60

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import math
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@ -54,6 +55,19 @@ def _resolve_range(
return _resolve_start(session, days, end), end return _resolve_start(session, days, end), end
def _range_days(start: datetime, end: datetime) -> int:
"""Number of calendar days a [start, end) range could span. Rounds up
(not truncates) the raw duration, since `start` isn't always exactly at
a day boundary -- "all time" starts at the first reading's actual
timestamp, which is very rarely local midnight -- and truncating there
would make `days_with_data` (a whole-calendar-day count) look bigger
than the range it's supposedly a fraction of. A day-aligned preset
(`days=N`, or a custom Y-M-D range) already lands on an exact multiple
of 24h, so rounding up is a no-op for those and only changes the
"all time" case."""
return max(1, math.ceil((end - start).total_seconds() / 86400))
def _bucket_key(local_date, granularity: str) -> str: def _bucket_key(local_date, granularity: str) -> str:
if granularity == "month": if granularity == "month":
return local_date.replace(day=1).isoformat() return local_date.replace(day=1).isoformat()
@ -215,7 +229,7 @@ def battery_history(
if row.battery_soc_percent is not None if row.battery_soc_percent is not None
] ]
days_with_data = len({row.ts.astimezone(BNE).date() for row in rows if row.battery_soc_percent is not None}) days_with_data = len({row.ts.astimezone(BNE).date() for row in rows if row.battery_soc_percent is not None})
range_days = max(1, (end - start).days) range_days = _range_days(start, end)
points = all_points points = all_points
if len(points) > _MAX_BATTERY_CHART_POINTS: if len(points) > _MAX_BATTERY_CHART_POINTS:
@ -245,7 +259,7 @@ def battery_value(
rows = get_usage_series(session, start, end) rows = get_usage_series(session, start, end)
current_plan = get_current_plan(session) current_plan = get_current_plan(session)
range_days = max(1, (end - start).days) range_days = _range_days(start, end)
battery_rows = [r for r in rows if r.battery_charge_w is not None or r.battery_discharge_w is not None] battery_rows = [r for r in rows if r.battery_charge_w is not None or r.battery_discharge_w is not None]
if not battery_rows or current_plan is None: if not battery_rows or current_plan is None:
return {"available": False, "range_days": range_days} return {"available": False, "range_days": range_days}