Rewrite battery-remaining estimate around per-wake drop rate
Build and push server image / build-and-push (push) Successful in 42s
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user