Rewrite battery-remaining estimate around per-wake drop rate
Build and push server image / build-and-push (push) Successful in 42s

The old estimate used a single linear percent/second rate from the
current discharge cycle's battery_history, which resets to empty on
every recharge -- so "not enough data yet" kept showing up despite the
frame having plenty of history overall, and the rate it did compute was
tied to whatever refresh interval produced it (changing the interval
didn't move the estimate until enough new history accumulated under
the new setting).

Now pulls the last 100 rows from the permanent battery_log table
instead, and averages the *per-wake* percent drop (not per-second) --
recharge jumps are skipped rather than counted as negative drain,
flat/zero-drop wakes still count so the rate isn't overstated, and
more recent steps are weighted more heavily. The per-wake rate then
converts to wall-clock time using the frame's current
refresh_interval_s and quiet-hours settings, so halving the refresh
interval roughly halves the estimate immediately, and quiet hours
correctly stretches it out (fewer wakes/day at the same per-wake cost).
This commit is contained in:
2026-07-22 21:21:17 -04:00
parent 3a0007118c
commit 95d69a5512
4 changed files with 89 additions and 21 deletions
+12
View File
@@ -114,6 +114,18 @@ def in_quiet_hours(cfg) -> bool:
return in_quiet
def quiet_span_s(cfg) -> int:
"""Seconds per day quiet hours keeps the device asleep -- 0 when
disabled. Used by common.py's battery-remaining estimate to turn a
per-wake battery cost into a wall-clock duration: quiet hours cuts
how many wakes happen per day without changing what any one wake
costs, so it belongs in the wakes-per-day math, not the per-wake
rate itself."""
if not cfg.quiet_hours_enabled:
return 0
return _quiet_hours_span_s(cfg.quiet_hours_start, cfg.quiet_hours_end)
def max_expected_gap_s(cfg) -> int:
"""Longest gap between wakes the device might legitimately have --
normally just refresh_interval_s, but quiet hours can make the real
+1 -1
View File
@@ -232,7 +232,7 @@ def api_queue(
"firmware_available": cfg.firmware_available_version,
"battery_percent": cfg.battery_percent,
"battery_as_of": cfg.battery_as_of,
"battery_estimate_s": battery_estimate_s(cfg),
"battery_estimate_s": battery_estimate_s(cfg, db),
"controller_id": cfg.controlled_by_user_id,
"controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username)
+73 -17
View File
@@ -19,7 +19,7 @@ from .. import calendar_feed, quiet_hours
from ..db import frame_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
from ..models import Frame, User, UserFrame
from ..models import BatteryLog, Frame, User, UserFrame
logger = logging.getLogger(__name__)
@@ -38,8 +38,8 @@ RECHARGE_JUMP_PCT = 5 # a report this much above the recent baseline = battery
# still needs to clear all of them, while a single stray low one doesn't
# get to set the bar.
RECHARGE_LOOKBACK = 3
MIN_ESTIMATE_SPAN_S = 2 * 3600 # need at least this much observed time...
MIN_ESTIMATE_DROP_PCT = 2 # ...and this much observed drop before estimating
BATTERY_ESTIMATE_SAMPLE_COUNT = 100 # most recent battery_log rows considered
MIN_ESTIMATE_SAMPLES = 5 # discharge steps needed before trusting the average
# "Overdue" threshold multiplier: the device should check in roughly every
# refresh_interval_s; give it half again as long before flagging it.
@@ -115,22 +115,78 @@ def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict
dither_strength=frame.dither_strength, manage=manage)
def battery_estimate_s(frame: Frame) -> int | None:
"""Linear remaining-time estimate from the current discharge cycle's
observed rate, or None when there's not enough signal to be honest
about (too little time observed, or too little drop -- a flat line
extrapolates to garbage)."""
hist = frame.battery_history
if len(hist) < 2:
def _avg_wake_interval_s(frame: Frame) -> float:
"""Average wall-clock seconds between wakes: refresh_interval_s
scaled up for however much of each day quiet hours removes from the
wake schedule entirely -- fewer wakes/day, not a cheaper wake. This
is what lets battery_estimate_s convert a per-wake drop rate into a
remaining-time estimate that reacts to both settings immediately,
rather than only after enough new history accumulates under them."""
active_day_s = max(1, 86400 - quiet_hours.quiet_span_s(frame))
interval_s = max(1, frame.refresh_interval_s)
wakes_per_day = max(1, active_day_s // interval_s)
return 86400 / wakes_per_day
def battery_estimate_s(frame: Frame, db: Session) -> int | None:
"""Remaining-time estimate from a recency-weighted average of the
*per-wake* percent drop, over the last BATTERY_ESTIMATE_SAMPLE_COUNT
rows of the permanent battery_log table -- not just the current
discharge cycle's battery_history, which resets to empty on every
recharge and so often doesn't hold enough signal on its own even
though the frame has plenty of history overall.
Consecutive reports are assumed to be consecutive wakes (firmware
reports battery on every wake while on battery), so each step's
(prev_percent - next_percent) is that wake's cost. A step where
percent went *up* is a recharge, not negative drain, and is skipped
entirely rather than folded in as a weird outlier; a flat step
(0% change) still counts as a real, cheap wake -- excluding those
would systematically overstate the per-wake cost by only counting
the wakes that happened to tick the percentage down. Steps are
weighted linearly by recency (step i of n gets weight i, 1-indexed)
so a recent change in usage pattern shows up quickly instead of
being washed out by a long flat history.
The resulting %/wake rate is then converted to wall-clock time using
the frame's *current* refresh_interval_s and quiet-hours settings
(see _avg_wake_interval_s), not whatever cadence produced the
historical data -- so halving refresh_interval_s roughly halves the
estimate immediately (not exactly halves: quiet hours removes a
fixed wake-free window from every day regardless of interval, which
is the "other things going on" that keeps the scaling sublinear)."""
if frame.battery_percent < 0:
return None
first_ts, first_pct = hist[0]
last_ts, last_pct = hist[-1]
span = last_ts - first_ts
drop = first_pct - last_pct
if span < MIN_ESTIMATE_SPAN_S or drop < MIN_ESTIMATE_DROP_PCT:
rows = db.execute(
select(BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
.order_by(BatteryLog.ts.desc())
.limit(BATTERY_ESTIMATE_SAMPLE_COUNT)
).scalars().all()
if len(rows) < MIN_ESTIMATE_SAMPLES + 1:
return None
rate = drop / span # percent per second
return int(last_pct / rate)
percents = list(reversed(rows)) # chronological order
weighted_drop_total = 0.0
weight_total = 0.0
valid_steps = 0
for i in range(1, len(percents)):
prev_pct, next_pct = percents[i - 1], percents[i]
if next_pct > prev_pct:
continue # recharge (or a swap) -- not a discharge sample
weight = i # later steps (larger i) count more
weighted_drop_total += weight * (prev_pct - next_pct)
weight_total += weight
valid_steps += 1
if valid_steps < MIN_ESTIMATE_SAMPLES or weight_total <= 0:
return None
avg_drop_per_wake = weighted_drop_total / weight_total
if avg_drop_per_wake <= 0:
return None # flat -- no honest rate to extrapolate
remaining_wakes = frame.battery_percent / avg_drop_per_wake
return int(remaining_wakes * _avg_wake_interval_s(frame))
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
+3 -3
View File
@@ -27,9 +27,9 @@ function renderDeviceStatusBar(device) {
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
// Shown as soon as there's any battery reading at all, even before
// battery_estimate_s can compute a rate (needs 2h+ span and a 2%+
// drop within the current discharge cycle -- see common.py) -- so
// it's clear the number is coming, not that the feature is broken.
// battery_estimate_s has enough discharge samples in battery_log to
// average (see common.py) -- so it's clear the number is coming, not
// that the feature is broken.
const hasEstimate = device.battery_estimate_s !== null && device.battery_estimate_s !== undefined;
rows.push([
'Est. battery life left',