From 444e70ad7726eaf1336cc39333b8a9a0cace5336 Mon Sep 17 00:00:00 2001 From: Fernando Gonzalez Date: Mon, 14 Sep 2026 22:20:42 -0400 Subject: [PATCH] feat: a calibration probe plan - two currents, each with a cold cable and a warm one The forecast backtest, once its sampling bias was controlled, left one error standing: heat soak from the charge before. It also showed why the history cannot resolve it - the controller chose every run's current on the strength of the model, so hot days and low currents arrive together and an ambient effect, a current-law error and a warm cable all wear the same signature. The fix is a designed experiment, not another fit. The probe becomes a plan: the product of --probe-amps (now a list) and --probe-cable. "cold" is a probe started in a session's first three minutes after --probe-cold-gap-h (4) without charging; "warm" is a mid-session step-down after --probe-warm-min (30) at full rate, uncapped; "any" is the original behavior and the default, so a single-current install is unchanged. The least-replicated condition goes first, and if it can still be met later in the session the daemon waits for it rather than spending the slot on an easier one, unless the slot is overdue by a whole interval. One probe per session. Weekly (--probe-plan-interval-days) until each condition has --probe-replicates (2), then monthly as before, cycling. The cadence is anchored at the probe's session start: anchored at its completion, a session exactly one interval later fell a few minutes short of due at its first tick - the only minutes a cold probe can start in. Each probe's start is recorded in its amp_capped event (detail.probe), and the backtest groups probe runs by condition from that instead of guessing from the current, which had been mis-tagging foldback runs as probes. Deployed as 32,40 x cold,warm this is eight probes over about two months - in a cooling season, also a fixed-current sweep across 15 C of ambient that the summer's controller-chosen history could not provide. Co-Authored-By: Claude Fable 5.1 --- contrib/backtest_forecast.py | 43 +++- contrib/derate_amp_control.py | 287 ++++++++++++++++++++++----- deploy/install-derate-amp-control.sh | 12 ++ docs/amp-control.md | 38 ++++ docs/thermal-model.md | 5 +- tests/test_backtest_forecast.py | 21 ++ tests/test_derate_amp_control.py | 146 ++++++++++++++ 7 files changed, 494 insertions(+), 58 deletions(-) diff --git a/contrib/backtest_forecast.py b/contrib/backtest_forecast.py index 61b4cf0..252799e 100644 --- a/contrib/backtest_forecast.py +++ b/contrib/backtest_forecast.py @@ -75,8 +75,6 @@ FIRST_TICK_S = 120.0 LAST_TICK_S = 1200.0 OPTIMISTIC_C = 2.0 # a plateau under-read by this much is the dangerous kind of wrong -PROBE_CURRENT_FRAC = 0.75 -PROBE_MIN_S = 1800.0 HOT_AMBIENT_C = 29.0 WARM_START_C = 3.0 # handle this far above its idle level at a session's first run BUCKETS_MIN = ((2, 5), (5, 10), (10, 20)) @@ -104,7 +102,8 @@ class Run: idle_ambient_c: float | None = None # from the idle handle before the session (first run) implied_ambient_c: float | None = None # this run's own trajectory, through the current law kind: str = "" # cold_start | warm_start | step_down | step_up - probe: bool = False + probe: bool = False # started by the amp controller's calibration probe (from its amp_capped event) + probe_cable: str | None = None # the probe's cable condition: any | cold | warm hot: bool = False sag_a: float = 0.0 free: bool = True # current held flat end to end: the plateau is the connector's own equilibrium @@ -134,6 +133,7 @@ class BoundaryScore: kind: str probe: bool hot: bool + probe_condition: str | None # "32A cold" etc., for grouping from_a: float | None to_a: float actual_c: float @@ -243,9 +243,28 @@ def params_without(fits: list[dict], sid: int, current_exp: float | None = None) ) +def probe_starts(db: Database, sess: dict) -> list[tuple[float, float, str]]: + """(ts, amps, cable) for every calibration probe the amp controller + started in this session, from its amp_capped events. Probes before the + plan existed carry no condition and read as "any".""" + starts = [] + for event in db.events_range(sess["start_ts"] - 60, sess["end_ts"] + 60, kinds=["amp_capped"]): + try: + detail = json.loads(event["detail"]) if event.get("detail") else {} + except ValueError: + continue + probe = detail.get("probe") + if probe: + starts.append((event["ts"], float(probe.get("amps") or 0.0), probe.get("cable") or "any")) + elif "calibration probe" in (detail.get("reason") or ""): + starts.append((event["ts"], float(detail.get("to_a") or 0.0), "any")) + return starts + + def build_runs(db: Database, sess: dict, params: thermal.ThermalParams, observe_tau: float, idle_model: thermal.IdleOffset, truth: str = "fit") -> list[Run]: rows = db.vitals_range(sess["start_ts"] - 1, sess["end_ts"] + 1, 500_000) + probes = probe_starts(db, sess) runs: list[Run] = [] for index, raw in enumerate(split_runs(rows)): samples = [(row["ts"], row["handle_temp_c"]) for row in raw] @@ -269,7 +288,12 @@ def build_runs(db: Database, sess: dict, params: thermal.ThermalParams, observe_ if len(samples) >= thermal.TRAJECTORY_MIN_SAMPLES: t_inf, _se = thermal._project_t_inf(samples, params.tau_min) run.implied_ambient_c = t_inf - params.rise_at(run.current_a) - run.probe = run.current_a <= PROBE_CURRENT_FRAC * thermal.REF_CURRENT_A and run.span_s >= PROBE_MIN_S + # A probe run begins within a couple of minutes of the controller's + # cap event (the ramp to the probe current falls into a run too + # short to keep) at that current. + for probe_ts, probe_amps, cable in probes: + if -30.0 <= run.start_ts - probe_ts <= 180.0 and abs(run.current_a - probe_amps) <= 2.0: + run.probe, run.probe_cable = True, cable ambient_for_hot = run.sensor_ambient_c if run.sensor_ambient_c is not None else run.idle_ambient_c run.hot = ambient_for_hot is not None and ambient_for_hot >= HOT_AMBIENT_C if index == 0: @@ -339,6 +363,7 @@ def score_boundary(run: Run, prev: Run | None, laws: dict[str, thermal.ThermalPa return None return BoundaryScore( run.session_id, run.kind, run.probe, run.hot, + f"{round(run.current_a):d}A {run.probe_cable}" if run.probe else None, prev.current_a if prev is not None else None, run.current_a, actual, predictions, se, ) @@ -365,8 +390,12 @@ def _stats(errors: list[float]) -> str: def _boundary_table(boundaries: list[BoundaryScore], kinds: list[str], with_se: bool = False) -> None: methods = sorted({m for b in boundaries for m in b.predictions}) groups: list[tuple[str, list[BoundaryScore]]] = [(kind, [b for b in boundaries if b.kind == kind]) for kind in kinds] - groups += [("probe", [b for b in boundaries if b.probe]), ("hot", [b for b in boundaries if b.hot]), - ("all", boundaries)] + groups += [("probe", [b for b in boundaries if b.probe])] + groups += [ + (f"probe {condition}", [b for b in boundaries if b.probe_condition == condition]) + for condition in sorted({b.probe_condition for b in boundaries if b.probe_condition}) + ] + groups += [("hot", [b for b in boundaries if b.hot]), ("all", boundaries)] for label, group in groups: if not group: continue @@ -503,7 +532,7 @@ def main(argv: list[str] | None = None) -> int: f"max {fmt(run.max_c)} | fit {fmt(run.fit_c)} (tau {fmt(run.fit_tau_min)}) " f"30min-fit {fmt(run.window_c)} | truth {fmt(run.plateau_c)} | " f"sensor {fmt(run.sensor_ambient_c)} implied {fmt(run.implied_ambient_c)} sag {run.sag_a:+.1f}" - f"{' probe' if run.probe else ''}{' hot' if run.hot else ''}" + f"{f' probe({run.probe_cable})' if run.probe else ''}{' hot' if run.hot else ''}" f"{'' if run.free else ' REGULATED'}{' TRIPPED' if run.tripped else ''}" f"{f' proj {run.projected_c:.1f}±{run.projected_se_c:.2f}' if run.projected_c is not None else ''}" ) diff --git a/contrib/derate_amp_control.py b/contrib/derate_amp_control.py index d052ba6..4a4d0d1 100755 --- a/contrib/derate_amp_control.py +++ b/contrib/derate_amp_control.py @@ -108,9 +108,13 @@ import sys import urllib.error import urllib.request -from dataclasses import asdict, dataclass, fields, replace +from dataclasses import asdict, dataclass, field, fields, replace TRIP_HANDLE_C = 65.0 # mirrors wallmonitor/thermal.py's TRIP_HANDLE_C (Gen 3, firmware 26.18.0) +COLD_START_WINDOW_S = 180.0 # a cold-cable probe must start within this long of charging beginning +FULL_RATE_FRAC = 0.9 # "at full rate" for the warm-cable clock: at least this fraction of normal_amps +PROBE_LOG_KEEP = 50 # completed probes remembered in the state file +PROBE_CABLES = ("any", "cold", "warm") @dataclass @@ -141,11 +145,35 @@ class Config: # a repeatable operating point that month-over-month comparison can use # without extrapolating across currents. # - # Disabled at 0 — this is opt-in, and an install whose charges already - # run unregulated at full rate does not need it. - probe_amps: float = 0.0 + # Disabled when empty — this is opt-in, and an install whose charges + # already run unregulated at full rate does not need it. + # + # A probe *plan* is the product of the currents and the cable + # conditions: "cold" is a probe started in a session's first minutes + # after a long gap since the last charge; "warm" is a mid-session + # step-down after a stretch at full rate, when the cable and connector + # are heat-soaked; "any" is the original behavior — whenever due. The + # backtest showed the forecast's remaining error is heat soak from the + # charge before, which the history cannot separate from ambient because + # the controller chose every run's current on the strength of the model + # itself; a probe at one current with a cold cable and a warm one + # measures it directly. Conditions are visited least-replicated first, + # at the plan interval until each has probe_replicates completions, then + # at the maintenance interval. + probe_amps: tuple[float, ...] = () + probe_cable: tuple[str, ...] = ("any",) probe_interval_days: float = 30.0 + probe_plan_interval_days: float = 7.0 + probe_replicates: int = 2 probe_hold_min: float = 40.0 + probe_cold_gap_h: float = 4.0 + probe_warm_min: float = 30.0 + + def __post_init__(self) -> None: + if isinstance(self.probe_amps, (int, float)): + self.probe_amps = (float(self.probe_amps),) if self.probe_amps > 0 else () + if isinstance(self.probe_cable, str): + self.probe_cable = (self.probe_cable,) @dataclass @@ -161,11 +189,25 @@ class State: last_session_state: str | None = None restore_attempts: int = 0 last_step_up_ts: float | None = None - # Calibration probe: when the current hold began, and when one last ran - # to completion. Only a *completed* hold updates last_probe_ts, so a - # session that unplugs mid-probe does not consume the month's slot. + # Calibration probe: when the current hold began, at what current and + # cable condition, and when one last ran to completion. Only a + # *completed* hold updates last_probe_ts and probes_done, so a session + # that unplugs mid-probe does not consume the slot. probe_session_ts + # is the session a probe was attempted in: one per session. probe_started_ts: float | None = None last_probe_ts: float | None = None + probe_amps: float | None = None + probe_cable: str | None = None + probe_session_ts: float | None = None + probes_done: list[dict] = field(default_factory=list) + # Session bookkeeping the cable conditions are judged on: the last tick + # seen charging (so the gap before a session is known), when this + # session's charging began, that gap, and how long the current has held + # at full rate uncapped. + last_charging_ts: float | None = None + charging_since_ts: float | None = None + charging_gap_s: float | None = None + full_rate_since_ts: float | None = None @dataclass @@ -175,6 +217,7 @@ class Action: kind: str # "none" | "cap" | "restore" value: float | None = None + probe: dict | None = None # {"amps", "cable"} when the cap starts a calibration probe def _decide_thermal(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str]: @@ -460,36 +503,77 @@ def _apply_probe( returns "none" and the step-up never happens. - **A probe only starts from above.** If the current is already at or under the probe current there is nothing to hold it down to. + - **One probe per session**, and the plan's least-replicated condition + first: if that condition can still be met later in this session (a + warm-cable probe needs a stretch at full rate first), the daemon + waits for it rather than spending the slot on an easier one — unless + the slot is overdue by a whole interval, when any eligible condition + will do. """ - if cfg.probe_amps <= 0: + if not cfg.probe_amps: return action, new_state, reason session_state = thermal.get("state") now_ts = thermal.get("ts") current_a = thermal.get("current_a") + has_ts = isinstance(now_ts, (int, float)) # Not charging: no probe can be running, and any half-finished one is # abandoned (its window never completed, so it taught nothing). if session_state != "charging": - return action, replace(new_state, probe_started_ts=None), reason + return action, replace( + new_state, probe_started_ts=None, probe_amps=None, probe_cable=None, + charging_since_ts=None, charging_gap_s=None, full_rate_since_ts=None, + ), reason + + # Session bookkeeping the cable conditions are judged on. The session + # started this tick if the previous run saw anything but "charging"; + # a daemon upgraded mid-session knows neither when it began nor the gap + # before it, and a cold probe simply waits for the next session. + if prev.last_session_state != "charging": + since = now_ts if has_ts else None + gap = (now_ts - prev.last_charging_ts) if has_ts and prev.last_charging_ts is not None else None + else: + since, gap = prev.charging_since_ts, prev.charging_gap_s + at_full_rate = ( + isinstance(current_a, (int, float)) and current_a >= FULL_RATE_FRAC * cfg.normal_amps and not new_state.capped + ) + full_since = (prev.full_rate_since_ts if prev.full_rate_since_ts is not None else now_ts) if at_full_rate and has_ts else None + new_state = replace( + new_state, + last_charging_ts=now_ts if has_ts else prev.last_charging_ts, + charging_since_ts=since, charging_gap_s=gap, full_rate_since_ts=full_since, + ) probing = prev.probe_started_ts is not None + # A state file written before the plan existed records no current for a + # hold in progress; that daemon had exactly one probe current. + active = (prev.probe_amps if prev.probe_amps is not None else cfg.probe_amps[0]) if probing else None - if action.kind == "cap" and action.value is not None and action.value < cfg.probe_amps: - if probing: + if action.kind == "cap" and action.value is not None: + if probing and action.value < active: return ( action, - replace(new_state, probe_started_ts=None), - f"{reason}; probe abandoned (thermal cap below the {cfg.probe_amps:g}A probe current)", + replace(new_state, probe_started_ts=None, probe_amps=None, probe_cable=None), + f"{reason}; probe abandoned (thermal cap below the {active:g}A probe current)", ) - return action, new_state, reason + if not probing: + return action, new_state, reason # a thermal decision this tick outranks starting a probe if probing: - if not isinstance(now_ts, (int, float)): + if not has_ts: return Action("none"), new_state, "probe holding (no timestamp to age it against)" held_min = (now_ts - prev.probe_started_ts) / 60.0 if held_min >= cfg.probe_hold_min: + record = {"ts": now_ts, "amps": active, "cable": prev.probe_cable or "any"} + # The cadence is anchored at the probe's *session* start, not its + # completion: a probe that ends 40-70 min into a session would + # otherwise leave a session exactly one interval later a few + # minutes short of due at its first tick — the only minutes a + # cold-cable probe can start in. + anchor = since if since is not None else now_ts done = replace( - new_state, probe_started_ts=None, last_probe_ts=now_ts, clear_streak=0, trip_streak=0 + new_state, probe_started_ts=None, probe_amps=None, probe_cable=None, last_probe_ts=anchor, + probes_done=(prev.probes_done + [record])[-PROBE_LOG_KEEP:], clear_streak=0, trip_streak=0, ) # The probe's own plateau is the best measurement of today's # conditions the session will ever have, and the server has @@ -499,12 +583,12 @@ def _apply_probe( sustainable = (thermal.get("forecast") or {}).get("sustainable_max_a") if isinstance(sustainable, (int, float)) and sustainable < cfg.normal_amps: target = float(sustainable) - if target <= cfg.probe_amps: + if target <= active: return ( Action("none"), - replace(done, capped=True, cap_value=cfg.probe_amps), + replace(done, capped=True, cap_value=active), ( - f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min; " + f"probe complete: held {active:g}A for {held_min:.0f}min; " f"sustainable {target:g}A is no higher, holding" ), ) @@ -512,7 +596,7 @@ def _apply_probe( Action("cap", target), replace(done, capped=True, cap_value=target, last_step_up_ts=now_ts), ( - f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min, " + f"probe complete: held {active:g}A for {held_min:.0f}min, " f"restoring to the sustainable {target:g}A" ), ) @@ -520,7 +604,7 @@ def _apply_probe( Action("restore", cfg.normal_amps), replace(done, capped=False, cap_value=None), ( - f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min, " + f"probe complete: held {active:g}A for {held_min:.0f}min, " f"restoring to {cfg.normal_amps:g}A" ), ) @@ -532,32 +616,84 @@ def _apply_probe( replace( new_state, probe_started_ts=prev.probe_started_ts, + probe_amps=active, + probe_cable=prev.probe_cable, capped=True, - cap_value=cfg.probe_amps, + cap_value=active, clear_streak=0, ), - f"probe holding {cfg.probe_amps:g}A ({held_min:.0f}/{cfg.probe_hold_min:g}min)", + f"probe holding {active:g}A ({held_min:.0f}/{cfg.probe_hold_min:g}min)", ) - # Start one? Only when due, only from a current above the probe value, - # and never on top of a thermal cap that is already lower — that session - # has bigger problems than calibration. - due = prev.last_probe_ts is None or ( - isinstance(now_ts, (int, float)) and (now_ts - prev.last_probe_ts) >= cfg.probe_interval_days * 86400.0 - ) - already_lower = new_state.capped and new_state.cap_value is not None and new_state.cap_value <= cfg.probe_amps - if due and not already_lower and isinstance(current_a, (int, float)) and current_a > cfg.probe_amps + 1.0: - return ( - Action("cap", cfg.probe_amps), - replace(new_state, probe_started_ts=now_ts, capped=True, cap_value=cfg.probe_amps), - ( - f"calibration probe due: capping to {cfg.probe_amps:g}A for {cfg.probe_hold_min:g}min " - "to measure an unregulated plateau" - ), - ) + # Start one? Only when due, once per session, from a current above the + # probe value, and never on top of a thermal cap that is already lower + # — that session has bigger problems than calibration. + plan = _probe_plan(cfg) + counts = _probe_counts(prev, plan) + complete = all(count >= cfg.probe_replicates for count in counts.values()) + interval_days = cfg.probe_interval_days if (complete or len(plan) == 1) else cfg.probe_plan_interval_days + if not has_ts or not isinstance(current_a, (int, float)): + return action, new_state, reason + elapsed = None if prev.last_probe_ts is None else now_ts - prev.last_probe_ts + if elapsed is not None and elapsed < interval_days * 86400.0: + return action, new_state, reason + if since is not None and prev.probe_session_ts == since: + return action, new_state, reason + overdue = elapsed is not None and elapsed >= 2.0 * interval_days * 86400.0 + for amps, cable in sorted(plan, key=lambda cond: (counts[cond], plan.index(cond))): + already_lower = new_state.capped and new_state.cap_value is not None and new_state.cap_value <= amps + if already_lower or current_a <= amps + 1.0: + continue + status = _cable_status(cable, now_ts, since, gap, full_since, new_state.capped, cfg) + if status == "eligible": + return ( + Action("cap", amps, probe={"amps": amps, "cable": cable}), + replace( + new_state, probe_started_ts=now_ts, probe_amps=amps, probe_cable=cable, + probe_session_ts=since, capped=True, cap_value=amps, + ), + ( + f"calibration probe due ({cable} cable): capping to {amps:g}A for {cfg.probe_hold_min:g}min " + "to measure an unregulated plateau" + ), + ) + if status == "pending" and not overdue: + return action, new_state, reason # the condition that needs it most may still come this session return action, new_state, reason +def _probe_plan(cfg: Config) -> list[tuple[float, str]]: + return [(amps, cable) for amps in cfg.probe_amps for cable in cfg.probe_cable] + + +def _probe_counts(state: State, plan: list[tuple[float, str]]) -> dict[tuple[float, str], int]: + counts = {condition: 0 for condition in plan} + for done in state.probes_done: + key = (float(done.get("amps") or 0.0), done.get("cable") or "any") + if key in counts: + counts[key] += 1 + return counts + + +def _cable_status( + cable: str, now_ts: float, since: float | None, gap_s: float | None, full_since: float | None, + capped: bool, cfg: Config, +) -> str: + """Whether a cable condition holds right now ("eligible"), could still + hold later in this session ("pending"), or cannot ("impossible").""" + if cable == "any": + return "eligible" + if cable == "cold": + if since is None or gap_s is None or gap_s < cfg.probe_cold_gap_h * 3600.0: + return "impossible" + return "eligible" if now_ts - since <= COLD_START_WINDOW_S else "impossible" + if cable == "warm": + if full_since is not None and now_ts - full_since >= cfg.probe_warm_min * 60.0: + return "eligible" + return "impossible" if capped else "pending" + return "impossible" + + def decide(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str]: """What to do, the state to persist, and why: the derate decision with the calibration probe layered over it.""" @@ -600,6 +736,8 @@ def event_for(action: Action, reason: str, thermal: dict, prev: State) -> tuple[ "steady_state_c": forecast.get("steady_state_c"), "handle_c": thermal.get("handle_c"), } + if action.probe is not None: + detail["probe"] = action.probe # the backtest groups probe runs by condition from this return ("amp_capped" if action.kind == "cap" else "amp_restored", detail) @@ -734,19 +872,52 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument( "--probe-amps", - type=float, - default=Config.probe_amps, + default="", help=( "calibration probe: hold this charge current on a fixed cadence so the degradation watch " - "gets a plateau nothing trimmed (0 disables, the default). Pick a current low enough that " - "neither this daemon nor the vehicle wants to reduce it" + "gets a plateau nothing trimmed (off by default). A comma-separated list probes each in " + "turn. Pick currents low enough that neither this daemon nor the vehicle wants to reduce them" + ), + ) + parser.add_argument( + "--probe-cable", + default="any", + help=( + "cable condition(s) to probe under, comma-separated: 'any' (whenever due, the default), " + "'cold' (a session's first minutes after --probe-cold-gap-h without charging), 'warm' (a " + "mid-session step-down after --probe-warm-min at full rate). With more than one current or " + "condition the plan visits the least-replicated one first" ), ) parser.add_argument( "--probe-interval-days", type=float, default=Config.probe_interval_days, - help="days between calibration probes (default: %(default)s)", + help="days between calibration probes once the plan is complete (default: %(default)s)", + ) + parser.add_argument( + "--probe-plan-interval-days", + type=float, + default=Config.probe_plan_interval_days, + help="days between probes while a multi-condition plan is still collecting (default: %(default)s)", + ) + parser.add_argument( + "--probe-replicates", + type=int, + default=Config.probe_replicates, + help="completed probes per condition before the plan counts as complete (default: %(default)s)", + ) + parser.add_argument( + "--probe-cold-gap-h", + type=float, + default=Config.probe_cold_gap_h, + help="hours without charging before a session counts as cold-cable (default: %(default)s)", + ) + parser.add_argument( + "--probe-warm-min", + type=float, + default=Config.probe_warm_min, + help="minutes at full rate, uncapped, before a warm-cable probe may start (default: %(default)s)", ) parser.add_argument( "--probe-hold-min", @@ -760,11 +931,22 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--dry-run", action="store_true", help="print the decision without changing the charger") args = parser.parse_args(argv) - if args.probe_amps and not (args.min_amps <= args.probe_amps <= args.normal_amps): - parser.error( - f"--probe-amps must sit between --min-amps ({args.min_amps:g}) and " - f"--normal-amps ({args.normal_amps:g}); got {args.probe_amps:g}" - ) + try: + probe_amps = tuple(float(part) for part in args.probe_amps.split(",") if part.strip()) + except ValueError: + parser.error(f"--probe-amps must be a number or a comma-separated list of numbers; got {args.probe_amps!r}") + probe_amps = tuple(amps for amps in probe_amps if amps > 0) + for amps in probe_amps: + if not (args.min_amps <= amps <= args.normal_amps): + parser.error( + f"--probe-amps must sit between --min-amps ({args.min_amps:g}) and " + f"--normal-amps ({args.normal_amps:g}); got {amps:g}" + ) + probe_cable = tuple(dict.fromkeys(part.strip() for part in args.probe_cable.split(",") if part.strip())) + if not probe_cable or any(cable not in PROBE_CABLES for cable in probe_cable): + parser.error(f"--probe-cable takes any of {', '.join(PROBE_CABLES)}; got {args.probe_cable!r}") + if "any" in probe_cable and len(probe_cable) > 1: + parser.error("--probe-cable 'any' cannot be combined with cold/warm") cfg = Config( normal_amps=args.normal_amps, @@ -778,9 +960,14 @@ def main(argv: list[str] | None = None) -> int: reattempt_window_min=args.reattempt_window_min, forecast_confidence_k=args.forecast_confidence_k, min_amps=args.min_amps, - probe_amps=args.probe_amps, + probe_amps=probe_amps, + probe_cable=probe_cable, probe_interval_days=args.probe_interval_days, + probe_plan_interval_days=args.probe_plan_interval_days, + probe_replicates=args.probe_replicates, probe_hold_min=args.probe_hold_min, + probe_cold_gap_h=args.probe_cold_gap_h, + probe_warm_min=args.probe_warm_min, ) state = load_state(args.state_file) diff --git a/deploy/install-derate-amp-control.sh b/deploy/install-derate-amp-control.sh index 05c3c6c..30552c8 100755 --- a/deploy/install-derate-amp-control.sh +++ b/deploy/install-derate-amp-control.sh @@ -10,6 +10,9 @@ # # add a monthly calibration probe (a held, unregulated charge the # # degradation watch can compare month to month): # sudo ./install-derate-amp-control.sh --tesla-ble http:// --probe-amps 32 +# # or a probe plan: two currents, each with a cold and a warm cable, weekly +# # until every condition has two, then monthly: +# sudo ./install-derate-amp-control.sh --tesla-ble http:// --probe-amps 32,40 --probe-cable cold,warm # sudo ./install-derate-amp-control.sh --uninstall # # The ESP32 host/IP lands only in the local systemd unit — never commit it. @@ -32,7 +35,10 @@ LEAD_TIME_MIN="" CONFIRM_TICKS="" MIN_CAP_DELTA_A="" PROBE_AMPS="" +PROBE_CABLE="" PROBE_INTERVAL_DAYS="" +PROBE_PLAN_INTERVAL_DAYS="" +PROBE_REPLICATES="" PROBE_HOLD_MIN="" STATE_FILE="" INTERVAL="30" @@ -52,7 +58,10 @@ while [[ $# -gt 0 ]]; do --confirm-ticks) CONFIRM_TICKS="$2"; shift 2 ;; --min-cap-delta-a) MIN_CAP_DELTA_A="$2"; shift 2 ;; --probe-amps) PROBE_AMPS="$2"; shift 2 ;; + --probe-cable) PROBE_CABLE="$2"; shift 2 ;; --probe-interval-days) PROBE_INTERVAL_DAYS="$2"; shift 2 ;; + --probe-plan-interval-days) PROBE_PLAN_INTERVAL_DAYS="$2"; shift 2 ;; + --probe-replicates) PROBE_REPLICATES="$2"; shift 2 ;; --probe-hold-min) PROBE_HOLD_MIN="$2"; shift 2 ;; --state-file) STATE_FILE="$2"; shift 2 ;; --interval) INTERVAL="$2"; shift 2 ;; @@ -97,7 +106,10 @@ DAEMON_ARGS="--tesla-ble ${TESLA_BLE} --wallmonitor ${WALLMONITOR}" [[ -n "$CONFIRM_TICKS" ]] && DAEMON_ARGS+=" --confirm-ticks ${CONFIRM_TICKS}" [[ -n "$MIN_CAP_DELTA_A" ]] && DAEMON_ARGS+=" --min-cap-delta-a ${MIN_CAP_DELTA_A}" [[ -n "$PROBE_AMPS" ]] && DAEMON_ARGS+=" --probe-amps ${PROBE_AMPS}" +[[ -n "$PROBE_CABLE" ]] && DAEMON_ARGS+=" --probe-cable ${PROBE_CABLE}" [[ -n "$PROBE_INTERVAL_DAYS" ]] && DAEMON_ARGS+=" --probe-interval-days ${PROBE_INTERVAL_DAYS}" +[[ -n "$PROBE_PLAN_INTERVAL_DAYS" ]] && DAEMON_ARGS+=" --probe-plan-interval-days ${PROBE_PLAN_INTERVAL_DAYS}" +[[ -n "$PROBE_REPLICATES" ]] && DAEMON_ARGS+=" --probe-replicates ${PROBE_REPLICATES}" [[ -n "$PROBE_HOLD_MIN" ]] && DAEMON_ARGS+=" --probe-hold-min ${PROBE_HOLD_MIN}" [[ -n "$STATE_FILE" ]] && DAEMON_ARGS+=" --state-file ${STATE_FILE}" [[ "$DRY_RUN" == "1" ]] && DAEMON_ARGS+=" --dry-run" diff --git a/docs/amp-control.md b/docs/amp-control.md index 362cf6e..8b9a0aa 100644 --- a/docs/amp-control.md +++ b/docs/amp-control.md @@ -164,6 +164,44 @@ Precedence is the whole contract, and it is deliberately simple: updates the cadence, so a session that unplugs early — or one that needed a real cap — simply retries next time. +### A probe plan + +One current, whenever due, gives the degradation watch its repeatable +point. It cannot tell the forecast the two things the recorded history +cannot: how rise scales with current in the band restores actually land in, +and how much of the handle's heat is the *cable* still warm from the charge +before. The [forecast backtest](thermal-model.md#measuring-the-forecast) +found that heat-soak history to be the forecast's one remaining error — and +found it inseparable from ambient in the history, because the controller +chose every run's current on the strength of the model itself. A probe at +the same current with a cold cable and a warm one measures it directly. + +```bash +sudo ./deploy/install-derate-amp-control.sh --tesla-ble http:// \ + --probe-amps 32,40 --probe-cable cold,warm +``` + +The plan is every current × every cable condition. **cold** is a probe +started in a session's first three minutes after `--probe-cold-gap-h` +(default 4) without charging; **warm** is a mid-session step-down after +`--probe-warm-min` (default 30) at full rate, uncapped; **any** is the +original behavior. The least-replicated condition goes first, and if it can +still be met later in the session — a warm probe needs its 30 min first — +the daemon waits for it rather than spending the slot on an easier one +(unless the slot is overdue by a whole interval, when anything eligible +will do). One probe per session. Probes run every +`--probe-plan-interval-days` (default 7) until each condition has +`--probe-replicates` (default 2) completions, then every +`--probe-interval-days` as before, cycling. Each probe's start is recorded +in its `amp_capped` event (`detail.probe`), which is how the backtest groups +probe runs by condition. + +Two currents × two conditions × two replicates is eight probes: about two +months at the weekly cadence, each costing ~40 min at reduced current. In a +cooling season that is also a fixed-current sweep across a 15 °C ambient +range — the test of an ambient effect that a summer's worth of controller- +chosen history could not provide. + The probe is off unless `--probe-amps` is set. An install whose charges already run unregulated at full rate does not need one; `regulated_n` on the Alerts page says whether yours does. diff --git a/docs/thermal-model.md b/docs/thermal-model.md index 8574eb5..e685d00 100644 --- a/docs/thermal-model.md +++ b/docs/thermal-model.md @@ -371,4 +371,7 @@ thing that stops the regression from separating a cap from a trend. The [amp controller](amp-control.md#the-calibration-probe) automates it — `--probe-amps 32` — because the component that moves charge current is the one that can hold it still on purpose. Without the controller, setting the -vehicle's charge limit by hand once a month does the same job. +vehicle's charge limit by hand once a month does the same job. A [probe +plan](amp-control.md#a-probe-plan) — two currents, each with a cold cable +and a warm one — is the designed experiment the +[backtest](#measuring-the-forecast) asked for. diff --git a/tests/test_backtest_forecast.py b/tests/test_backtest_forecast.py index d8f7b84..47e4e82 100644 --- a/tests/test_backtest_forecast.py +++ b/tests/test_backtest_forecast.py @@ -103,6 +103,27 @@ def test_backtest_scores_a_cold_start_and_a_step_down(db, tmp_path, capsys): assert {m.split("/")[0] for m in start[0]["predictions"]} == {"sensor", "idle"} +def test_backtest_tags_probe_runs_from_the_controller_event(db, tmp_path, capsys): + now = time.time() + for i in range(4): + _seed(db, now - (8 - i) * 7200, ambient_c=25.0, steps=[(48.6, 2400)]) + start = now - 7200 + sid = _seed(db, start, ambient_c=25.0, steps=[(48.6, 2700), (32.0, 2700)]) + db.add_event(start + 2700 - 20, "amp_capped", { + "to_a": 32.0, "reason": "calibration probe due (warm cable): capping to 32A for 40min", + "probe": {"amps": 32.0, "cable": "warm"}, + }) + out = tmp_path / "bt.json" + assert bt.main(["--db", str(tmp_path / "test.db"), "--json", str(out)]) == 0 + import json + data = json.loads(out.read_text()) + step = [r for r in data["runs"] if r["session_id"] == sid and r["kind"] == "step_down"] + assert len(step) == 1 and step[0]["probe"] is True and step[0]["probe_cable"] == "warm" + assert "probe 32A warm" in capsys.readouterr().out + # The full-rate run before it is not a probe. + assert not [r for r in data["runs"] if r["session_id"] == sid and r["kind"] == "cold_start"][0]["probe"] + + def test_split_runs_drops_ramp_samples_and_splits_on_a_current_change(): def row(i, amps, closed=1): return {"ts": 1000.0 + 2.0 * i, "contactor_closed": closed, "vehicle_current_a": amps, "handle_temp_c": 30.0} diff --git a/tests/test_derate_amp_control.py b/tests/test_derate_amp_control.py index 0ba71d3..d00d0d0 100644 --- a/tests/test_derate_amp_control.py +++ b/tests/test_derate_amp_control.py @@ -19,6 +19,8 @@ import pathlib import sys +import pytest + spec = importlib.util.spec_from_file_location( "derate_amp_control", pathlib.Path(__file__).parent.parent / "contrib" / "derate_amp_control.py", @@ -670,6 +672,142 @@ def test_probe_does_not_start_from_a_lower_thermal_cap(): assert new.probe_started_ts is None +def _plan_cfg(**kw): + kw.setdefault("probe_amps", (32.0, 40.0)) + kw.setdefault("probe_cable", ("cold", "warm")) + kw.setdefault("probe_replicates", 2) + kw.setdefault("probe_plan_interval_days", 7.0) + kw.setdefault("probe_interval_days", 30.0) + kw.setdefault("probe_hold_min", 40.0) + kw.setdefault("probe_cold_gap_h", 4.0) + kw.setdefault("probe_warm_min", 30.0) + return _cfg(**kw) + + +def _simulate(cfg, days, session_min=120, sessions_per_day=1, start_state=None, capped_at=None): + """Walk decide() through `days` of daily charging sessions, 30 s ticks, + the car following every cap instantly and a clear, cool forecast + throughout (sustainable = full rate). Returns (probe starts as + [(day, minute_into_session, amps, cable)], final state).""" + t0 = 1_000_000.0 + # A fresh state file knows no previous charge, so day 0 could never be + # cold-cable; give the daemon a last charge 12 h before the timeline. + state = start_state or dac.State(last_charging_ts=t0 - 12 * 3600) + starts = [] + for day in range(days): + for n in range(sessions_per_day): + begin = t0 + day * 86400 + n * (24 * 3600 / sessions_per_day) + # an idle tick shortly before charging begins + _, state, _ = dac.decide(_thermal(state="idle", ts=begin - 60, current_a=0.0), state, cfg) + ts = begin + while ts < begin + session_min * 60: + if capped_at is not None: + current = capped_at + state = dac.replace(state, capped=True, cap_value=capped_at) if not state.probe_started_ts else state + else: + current = state.cap_value if state.capped and state.cap_value else cfg.normal_amps + snap = _thermal(will_trip=False, mtt=None, suggested=None, handle_c=45.0, ts=ts, + current_a=current, sustainable=cfg.normal_amps) + action, state, reason = dac.decide(snap, state, cfg) + if action.probe is not None: + starts.append((day, round((ts - begin) / 60), action.probe["amps"], action.probe["cable"])) + ts += 30.0 + _, state, _ = dac.decide(_thermal(state="idle", ts=ts + 60, current_a=0.0), state, cfg) + return starts, state + + +def test_probe_plan_visits_every_condition_least_replicated_first_then_goes_monthly(): + starts, state = _simulate(_plan_cfg(), days=85) + # Weekly while collecting: cold probes open a session, warm ones wait + # for 30 min at full rate in the same session; then two of each and the + # cadence relaxes to monthly, cycling from the top of the plan again. + assert [(d, a, c) for d, _, a, c in starts] == [ + (0, 32.0, "cold"), (7, 32.0, "warm"), (14, 40.0, "cold"), (21, 40.0, "warm"), + (28, 32.0, "cold"), (35, 32.0, "warm"), (42, 40.0, "cold"), (49, 40.0, "warm"), + (79, 32.0, "cold"), + ] + assert all(m == 0 for _, m, _, c in starts if c == "cold") + assert all(m == 30 for _, m, _, c in starts if c == "warm") + assert len(state.probes_done) == 9 + assert {(p["amps"], p["cable"]) for p in state.probes_done[:8]} == { + (32.0, "cold"), (32.0, "warm"), (40.0, "cold"), (40.0, "warm")} + assert not state.capped # every probe restored afterwards + + +def test_probe_cold_needs_a_long_gap_since_the_last_charge(): + # Sessions two hours apart never let the cable cool: a cold-only plan + # starts nothing, however overdue it gets. + busy = dac.State(last_charging_ts=1_000_000.0 - 3600) + starts, _ = _simulate(_plan_cfg(probe_cable=("cold",)), days=21, session_min=60, sessions_per_day=12, + start_state=busy) + assert starts == [] + # A fresh state file knows no previous charge: the first session cannot + # be called cold, the second can. + starts, _ = _simulate(_plan_cfg(probe_cable=("cold",)), days=2, start_state=dac.State()) + assert [(d, m) for d, m, _, _ in starts] == [(1, 0)] + # ...and a daemon upgraded mid-session does not know when it began. + mid = dac.State(last_session_state="charging", last_charging_ts=1_000_000.0 - 30) + action, new, _ = dac.decide(_thermal(will_trip=False, ts=1_000_000.0), mid, _plan_cfg(probe_cable=("cold",))) + assert action.probe is None and new.charging_since_ts is None + + +def test_probe_warm_needs_full_rate_uncapped_first(): + # With a thermal cap standing the current is not full rate, so a + # warm-only plan cannot start; the restore path is not blocked by the + # wait either. + starts, _ = _simulate(_plan_cfg(probe_cable=("warm",)), days=8, capped_at=40.0) + assert starts == [] + # Uncapped, it starts exactly at the warm threshold and never at the session's opening. + starts, _ = _simulate(_plan_cfg(probe_cable=("warm",)), days=8) + assert [(d, m) for d, m, _, _ in starts] == [(0, 30), (7, 30)] + + +def test_probe_waits_for_the_condition_that_needs_it_most_unless_overdue(): + cfg = _plan_cfg(probe_amps=(32.0,)) + done = [{"ts": 1.0, "amps": 32.0, "cable": "cold"}] * 2 # cold has its replicates, warm has none + last_charge = 1_000_000.0 - 12 * 3600 + fresh = dac.State(probes_done=done, last_probe_ts=1_000_000.0 - 8 * 86400, last_charging_ts=last_charge) + starts, _ = _simulate(cfg, days=1, start_state=fresh) + assert [(m, c) for _, m, _, c in starts] == [(30, "warm")] # cold was eligible at minute 0 and was passed over + # Fifteen days since the last probe — more than twice the plan interval + # — and the slot goes to whatever is eligible now. + overdue = dac.State(probes_done=done, last_probe_ts=1_000_000.0 - 15 * 86400, last_charging_ts=last_charge) + starts, _ = _simulate(cfg, days=1, start_state=overdue) + assert [(m, c) for _, m, _, c in starts] == [(0, "cold")] + + +def test_probe_single_current_any_cable_keeps_the_monthly_cadence(): + # The pre-plan configuration: one current, whenever due. The plan + # interval and the replicate count must not touch it. + starts, _ = _simulate(_probe_cfg(probe_replicates=2, probe_plan_interval_days=7.0), days=45) + assert [(d, c) for d, _, _, c in starts] == [(0, "any"), (30, "any")] + + +def test_probe_one_per_session(): + # A 3 h session with a 32 A cold probe complete at 40 min: the warm + # condition becomes eligible later in the same session and must wait + # for the next one. + starts, _ = _simulate(_plan_cfg(probe_amps=(32.0,)), days=2, session_min=180) + assert [(d, c) for d, _, _, c in starts] == [(0, "cold")] # day 1 is inside the 7-day interval + + +def test_probe_start_event_carries_its_condition(): + action = dac.Action("cap", 40.0, probe={"amps": 40.0, "cable": "warm"}) + kind, detail = dac.event_for(action, "calibration probe due (warm cable)", _thermal(), dac.State()) + assert kind == "amp_capped" and detail["probe"] == {"amps": 40.0, "cable": "warm"} + _, plain = dac.event_for(dac.Action("cap", 44.0), "trip", _thermal(), dac.State()) + assert "probe" not in plain + + +def test_probe_cli_parses_a_plan_and_rejects_a_bad_one(capsys): + unreachable = ["--tesla-ble", "http://127.0.0.1:1", "--wallmonitor", "http://127.0.0.1:1", "--dry-run"] + assert dac.main(["--probe-amps", "32,40", "--probe-cable", "cold,warm", *unreachable]) == 0 + assert "cannot reach wallmonitor" in capsys.readouterr().err + for bad in (["--probe-cable", "any,cold"], ["--probe-cable", "hot"], ["--probe-amps", "32,x"], ["--probe-amps", "60"]): + with pytest.raises(SystemExit): + dac.main([*bad, *unreachable]) + + def test_probe_state_survives_an_old_state_file(tmp_path): # A daemon upgrade reads state written before the probe existed. It must # default the new fields rather than crash — and a probe must then be @@ -684,3 +822,11 @@ def test_probe_state_survives_an_old_state_file(tmp_path): # and the round-trip back to disk keeps the new fields dac.save_state(str(path), dac.State(probe_started_ts=1.0, last_probe_ts=2.0)) assert dac.load_state(str(path)).last_probe_ts == 2.0 + # A hold in progress recorded before the plan existed carries no + # current; that daemon had exactly one, and the hold continues at it. + path.write_text('{"capped": true, "cap_value": 32.0, "last_session_state": "charging", ' + '"probe_started_ts": 999000.0}') + state = dac.load_state(str(path)) + assert state.probes_done == [] and state.probe_amps is None + action, new, reason = dac.decide(_thermal(will_trip=False), state, _probe_cfg()) + assert action.kind == "none" and "probe holding 32A" in reason and new.cap_value == 32.0