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