"""SAFETY-97: what a battery's power reading shows while it gives nothing. B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live 2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded against this idle reading, not against 0 W, or a refusing B never latches. DECISIONS/SAFETY-97-idle-offset.md. Learned per unit, never configured: - only while control runs, the unit is not refused, stale or tripped, and its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery is asked more than that, so it cannot teach its refusal as idle); - a reading beyond IDLE_SAMPLE_W is not a sample; - first value: the mean of IDLE_FIRST_S of samples; then a slow follow (IDLE_TAU_S); - persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start. The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until learned the idle reading is 0 W, the rule as it was. """ import math IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0" IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value IDLE_TAU_S = 1800.0 # then follow drift this slowly IDLE_SAVE_W = 5.0 # persist when the value moved this far IDLE_GAP_S = 10.0 # a longer gap between reads counts as this def _num(v): try: v = float(v) except (TypeError, ValueError): return None return v if math.isfinite(v) else None class IdleReading: """One unit's idle reading. `w` is what "gives nothing" is centred on.""" def __init__(self, saved=None): v = _num(saved) self.value = (None if v is None else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v))) self.saved = self.value self.quiet_since = None self.last = None self.acc_w = 0.0 self.acc_s = 0.0 @property def w(self) -> float: """The centre used: the learned value clamped to IDLE_MAX_W.""" if self.value is None: return 0.0 return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value)) def observe(self, reading, standing_w, now_mono, may_learn) -> bool: """One read. Returns True when the value should be persisted.""" now = float(now_mono) dt = (0.0 if self.last is None else max(0.0, min(IDLE_GAP_S, now - self.last))) self.last = now standing = _num(standing_w) if not may_learn or standing is None or abs(standing) > IDLE_ASK_W: self.quiet_since = None return False if self.quiet_since is None: self.quiet_since = now b = _num(reading) if (now - self.quiet_since < IDLE_QUIET_S or b is None or abs(b) > IDLE_SAMPLE_W or dt <= 0.0): return False if self.value is None: self.acc_w += b * dt self.acc_s += dt if self.acc_s < IDLE_FIRST_S: return False self.value = self.acc_w / self.acc_s else: self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S) if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W: self.saved = self.value return True return False