Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 160 additions & 2 deletions contrib/derate_amp_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ class Config:
forecast_confidence_k: float = 2.0
min_amps: float = 6.0

# Calibration probe. The degradation watch can only compare windows the
# charge current held steady through — and on an install where this
# daemon caps often, those are scarce and land at whatever current the
# capping happened to choose. A probe manufactures one deliberately:
# hold a current low enough that nothing wants to trim it, long enough
# for the handle to reach its plateau, on a fixed cadence. The result is
# 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
probe_interval_days: float = 30.0
probe_hold_min: float = 40.0


@dataclass
class State:
Expand All @@ -137,6 +152,11 @@ 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.
probe_started_ts: float | None = None
last_probe_ts: float | None = None


@dataclass
Expand All @@ -148,8 +168,9 @@ class Action:
value: float | None = None


def decide(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str]:
"""Pure decision logic: what to do, the state to persist, and why."""
def _decide_thermal(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str]:
"""The derate-avoidance decision: what to do, the state to persist, and
why. Knows nothing about the calibration probe — see _apply_probe."""
session_state = thermal.get("state")
forecast = thermal.get("forecast") or {}
basis = forecast.get("basis")
Expand Down Expand Up @@ -403,6 +424,109 @@ def decide(thermal: dict, state: State, cfg: Config) -> tuple[Action, State, str
)


def _apply_probe(
action: Action, new_state: State, reason: str, thermal: dict, prev: State, cfg: Config
) -> tuple[Action, State, str]:
"""Overlay the calibration probe on the derate decision.

A layer rather than a branch inside _decide_thermal, because that logic
is tuned by a string of live incidents and the probe has no business
reaching into it. The precedence is the only thing that matters here:

- **Safety always wins.** A cap below the probe current is a real
thermal decision; it is applied, and the probe is abandoned rather
than held over a window whose current just moved. An abandoned probe
does not update last_probe_ts, so the next session retries it.
- **The probe outranks restoring.** Stepping back up toward full rate is
exactly what would ruin the measurement, so while a probe holds, this
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.
"""
if cfg.probe_amps <= 0:
return action, new_state, reason
session_state = thermal.get("state")
now_ts = thermal.get("ts")
current_a = thermal.get("current_a")

# 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

probing = prev.probe_started_ts is not None

if action.kind == "cap" and action.value is not None and action.value < cfg.probe_amps:
if probing:
return (
action,
replace(new_state, probe_started_ts=None),
f"{reason}; probe abandoned (thermal cap below the {cfg.probe_amps:g}A probe current)",
)
return action, new_state, reason

if probing:
if not isinstance(now_ts, (int, float)):
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:
return (
Action("restore", cfg.normal_amps),
replace(
new_state,
probe_started_ts=None,
last_probe_ts=now_ts,
capped=False,
cap_value=None,
clear_streak=0,
trip_streak=0,
),
(
f"probe complete: held {cfg.probe_amps:g}A for {held_min:.0f}min, "
f"restoring to {cfg.normal_amps:g}A"
),
)
# Hold. clear_streak is pinned at zero so the restore path cannot
# bank confirming polls while the probe runs and then step up the
# instant it ends.
return (
Action("none"),
replace(
new_state,
probe_started_ts=prev.probe_started_ts,
capped=True,
cap_value=cfg.probe_amps,
clear_streak=0,
),
f"probe holding {cfg.probe_amps: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"
),
)
return action, new_state, reason


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."""
action, new_state, reason = _decide_thermal(thermal, state, cfg)
return _apply_probe(action, new_state, reason, thermal, state, cfg)


# ---------------------------------------------------------------------------
# wallmonitor / tesla-ble access

Expand Down Expand Up @@ -569,9 +693,40 @@ def main(argv: list[str] | None = None) -> int:
default="/tmp/derate_amp_control.state.json",
help="remembers cap state and debounce streaks between runs",
)
parser.add_argument(
"--probe-amps",
type=float,
default=Config.probe_amps,
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"
),
)
parser.add_argument(
"--probe-interval-days",
type=float,
default=Config.probe_interval_days,
help="days between calibration probes (default: %(default)s)",
)
parser.add_argument(
"--probe-hold-min",
type=float,
default=Config.probe_hold_min,
help=(
"minutes to hold the probe current; needs to exceed ~3x the install's thermal time "
"constant for the handle to reach its plateau (default: %(default)s)"
),
)
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}"
)

cfg = Config(
normal_amps=args.normal_amps,
lead_time_min=args.lead_time_min,
Expand All @@ -584,6 +739,9 @@ 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_interval_days=args.probe_interval_days,
probe_hold_min=args.probe_hold_min,
)
state = load_state(args.state_file)

Expand Down
12 changes: 12 additions & 0 deletions deploy/install-derate-amp-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
# Usage (run with sudo, from anywhere):
# sudo ./install-derate-amp-control.sh --tesla-ble http://<esp32-host>
# sudo ./install-derate-amp-control.sh --tesla-ble http://<esp32-host> --dry-run
# # 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://<esp32-host> --probe-amps 32
# sudo ./install-derate-amp-control.sh --uninstall
#
# The ESP32 host/IP lands only in the local systemd unit — never commit it.
Expand All @@ -28,6 +31,9 @@ NORMAL_AMPS=""
LEAD_TIME_MIN=""
CONFIRM_TICKS=""
MIN_CAP_DELTA_A=""
PROBE_AMPS=""
PROBE_INTERVAL_DAYS=""
PROBE_HOLD_MIN=""
STATE_FILE=""
INTERVAL="30"
DRY_RUN="0"
Expand All @@ -45,6 +51,9 @@ while [[ $# -gt 0 ]]; do
--lead-time-min) LEAD_TIME_MIN="$2"; shift 2 ;;
--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-interval-days) PROBE_INTERVAL_DAYS="$2"; shift 2 ;;
--probe-hold-min) PROBE_HOLD_MIN="$2"; shift 2 ;;
--state-file) STATE_FILE="$2"; shift 2 ;;
--interval) INTERVAL="$2"; shift 2 ;;
--dry-run) DRY_RUN="1"; shift ;;
Expand Down Expand Up @@ -87,6 +96,9 @@ DAEMON_ARGS="--tesla-ble ${TESLA_BLE} --wallmonitor ${WALLMONITOR}"
[[ -n "$LEAD_TIME_MIN" ]] && DAEMON_ARGS+=" --lead-time-min ${LEAD_TIME_MIN}"
[[ -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_INTERVAL_DAYS" ]] && DAEMON_ARGS+=" --probe-interval-days ${PROBE_INTERVAL_DAYS}"
[[ -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"
[[ -n "$EXTRA_ARGS" ]] && DAEMON_ARGS+=" ${EXTRA_ARGS}"
Expand Down
50 changes: 50 additions & 0 deletions docs/amp-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,56 @@ records `amp_adjust_failed`, so bridge flakiness shows up in the same place
as the decisions it blocked. Recording is best-effort by design: the event
log is observability, never control flow.

## The calibration probe

The daemon exists to move charge current, which makes it the one component
that can *stop* moving it on purpose — and that turns out to be worth as much
as the capping.

The [degradation watch](thermal-model.md#degradation-watch) can only compare
charges whose current held steady through the ramp; anything else fits a
plateau that a current change produced rather than the connector. On an
install where this daemon caps often, those steady windows are scarce and
land at whatever current the capping happened to stop at — which is a moving
target, and one correlated with the weather, since a hot garage triggers
capping sooner. Measured on one install: 285 caps in a month, 7 of 18 fitted
windows contaminated, and the surviving ones split across three different
currents.

`--probe-amps` fixes that by manufacturing a clean window on a cadence:

```bash
sudo ./deploy/install-derate-amp-control.sh --tesla-ble http://<esp32-host> --probe-amps 32
```

Once every `--probe-interval-days` (default 30), the first charging session
to come along is held at `--probe-amps` for `--probe-hold-min` (default 40)
minutes, then released. Pick a current low enough that neither this daemon
nor the vehicle wants to reduce it — on a 48 A install where foldback starts
around 61 °C, 32 A plateaus near 53 °C with room to spare. Hold it for more
than ~3x the install's time constant (`model.tau_min` in `/api/thermal`) so
the handle actually reaches that plateau instead of being extrapolated to it.

The result is a repeatable operating point: same current, unregulated, once a
month. Comparing those to each other is a degradation test with no
extrapolation across currents in it at all. They also break the collinearity
between charge current and the calendar, which is what otherwise stops the
watch's regression from telling a cap apart from a trend.

Precedence is the whole contract, and it is deliberately simple:

- **A thermal cap below the probe current always wins.** It is applied, and
the probe is abandoned — a window whose current just moved teaches nothing.
- **The probe outranks restoring.** Stepping back toward full rate is exactly
what would ruin the measurement, so no step-up happens while it holds.
- **An abandoned probe does not count.** Only a hold that ran its full length
updates the cadence, so a session that unplugs early — or one that needed a
real cap — simply retries next time.

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.

**Checking whether it would actually help, before or after deploying it:**
`contrib/backtest_derate_amp_control.py` replays `decide()` against real
historical sessions read straight from `wallmonitor.db` (point it at a copy,
Expand Down
Loading
Loading