Skip to content

Commit 91e58cc

Browse files
Antonio ...claude
andcommitted
fix: v2.13.2 — Hybrid Inverter Mode ramp no longer stalls mid-way
RIDING_EDGE only stepped up while grid import stayed below half of the grid-import threshold and held the amperage forever between half and the full threshold. Zero-export inverters regulate to a small residual import that grows with the load, so the ramp froze (~10 A on a typical install) with plenty of curtailed PV still available. The ramp now climbs whenever import is within the threshold. Anti-oscillation moved to the amperage domain: a level that forced a step-down is blocked for 10 minutes (edge ceiling), then the edge is re-probed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3d3bfe0 commit 91e58cc

6 files changed

Lines changed: 234 additions & 20 deletions

File tree

‎CLAUDE.md‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
77
This is a **Home Assistant custom integration** for intelligent EV charging control. It manages EV charger automation based on solar production, time of day, battery levels, grid import protection, and intelligent priority balancing between EV and home battery charging.
88

99
**Domain:** `ev_smart_charger`
10-
**Current Version:** 2.13.1
10+
**Current Version:** 2.13.2
1111
**Installation:** HACS custom repository or manual installation to `custom_components/ev_smart_charger`
1212

1313
## Development Commands
@@ -757,6 +757,48 @@ async def _set_amperage(self, target_amperage: int):
757757

758758
## Version History
759759

760+
### v2.13.2 (2026-09-28)
761+
**FIX: Hybrid Inverter Mode RIDING_EDGE ramp froze mid-way (~10 A) despite PV headroom**
762+
763+
`_handle_riding_edge` had three grid-import regions: step down when
764+
`grid_import > threshold` is sustained, step up only when
765+
`grid_import <= threshold / 2` for `HYBRID_HEADROOM_STABLE_SECONDS`, and
766+
**hold forever** in between. Zero-export inverters regulate to a small
767+
residual import that grows with the load (a CT control loop with a
768+
percentage deadband), so once that residual crossed `threshold / 2`
769+
(25 W at the default 50 W) the ramp neither climbed nor stepped down — on a
770+
typical install that happened around 10 A, with kilowatts of curtailed PV
771+
still available. Reproduced in a simulation with a 1.5 %-of-load residual:
772+
the old code sat at 8–10 A indefinitely.
773+
774+
Fix: the step-up path now runs whenever `grid_import <= threshold` (the
775+
import is within the user's own budget). Anti-oscillation moved from the
776+
import domain to the amperage domain: every step-down (grid or battery
777+
masking) records the tripped level as an **edge ceiling**
778+
(`_set_edge_ceiling`), and the ramp may not climb back onto it for
779+
`HYBRID_EDGE_CEILING_SECONDS = 600`; after expiry the edge is re-probed once
780+
(PV availability changes through the day). An over-threshold tick now also
781+
clears `_headroom_ok_since`, so a step-up always needs a fresh stable window,
782+
and the import-violation timer resets as soon as import is back within the
783+
threshold (previously only below half of it). The ceiling is cleared on any
784+
return to IDLE, surfaced in the diagnostic snapshot (`edge_ceiling_amps`,
785+
`edge_ceiling_until`) and in the diagnostic state
786+
(`holding below {N}A import ceiling`). The Generic 2-level jump (issue #38)
787+
respects the ceiling. PROBING and IDLE-entry smoothing are unchanged.
788+
789+
**Files**: `hybrid_inverter_mode.py`, `const.py` (`HYBRID_EDGE_CEILING_SECONDS`,
790+
VERSION), `manifest.json`, `README.md`, this file; tests: NEW
791+
`tests/test_v2132_hybrid_ramp.py` (4: ramp reaches 32 A with zero import,
792+
residual import within the threshold no longer freezes the ramp, a step-down
793+
blocks re-climbing until the ceiling expires then re-probes, sustained import
794+
still steps down). `VERSION = "2.13.2"`. No schema / entity / config-flow
795+
change, entity counts unchanged (74 / 60). Full suite green:
796+
**368 passed / 0 failed**.
797+
798+
**Upgrade priority**: 🟢 RECOMMENDED for every Hybrid Inverter Mode user.
799+
800+
---
801+
760802
### v2.13.1 (2026-09-25)
761803
**FIX: Boost Charge aborted when enabled on an already-charging vehicle (issue #60)**
762804

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Most projects target one charger brand or assume a single-phase home. EV Smart C
5656
5757
> **🆕 New in v2.6.0** — mass bug-fix & improvement release (issues #36–#42). Highlights: an optional **`grid_available` binary sensor** ([issue #36](https://github.com/antbald/ha-ev-smart-charger/issues/36)) stops Night Smart Charge grid mode during a **grid outage** so it doesn't drain the home battery on hybrid Battery First / UPS inverters (fail-safe: an unavailable sensor never triggers a false stop); a **customizable nighttime window** ([issue #42](https://github.com/antbald/ha-ev-smart-charger/issues/42)) via two opt-in offset numbers (start before sunset / end after sunrise); and a big **INFO log-noise reduction** ([issue #40](https://github.com/antbald/ha-ev-smart-charger/issues/40)) — idle ticks no longer flood the HA log. Also: valid day icons (#37), faster Generic-charger ramp-up (#38), a consistent Hybrid full-battery threshold (#39), and a cleaner telemetry log (#41). **All backward compatible** — the two features are opt-in.
5858
59+
> **⚡ New in v2.13.2** — **Hybrid Inverter Mode no longer stalls mid-ramp.** In RIDING_EDGE the amperage only stepped up while grid import stayed below *half* of `evsc_grid_import_threshold`, and was held forever between half and the full threshold. Zero-export inverters regulate to a small residual import that grows with the load, so on many installs the ramp froze around 10 A even with kilowatts of PV headroom left. The ramp now climbs whenever import is within the threshold; anti-oscillation moved to the amperage domain — a level that forced a step-down is not re-tried for 10 minutes, after which the edge is probed again. **RECOMMENDED for every Hybrid Inverter Mode user.**
60+
5961
> **📱 New in v2.12.0 — full iOS Live Activities support.** Now that the Companion App renders Live Activities on the iPhone Lock Screen and Dynamic Island, EV charging Live Activities are **ON by default** and their update policy has been rewritten around Apple's real constraints: a single `evsc_ev_charging` activity is pushed only on discrete state changes (mode switch, EV target change, every 5 points of EV SOC), never on the continuously moving charging power, amperage or wallbox status — so the card moves when something actually happens instead of once a minute. Covers Boost Charge, Night Smart Charge, Solar Surplus, Force Charge, and normal charging. Requires HA Core 2026.7+ and iOS 17.2+ (Android 16+ for Live Updates), plus allowing Live Activities in **iOS Settings → Home Assistant**.
6062
6163
> **🔌 New in v2.11.0** — **auto-disarm Force Charge on unplug** (opt-in). Force Charge means "charge now, whatever the automations think" — an intent that ends with that session, except the switch stays ON. Enable `evsc_force_charge_auto_disarm` (Dashboard → Settings → 🛡 Safety) and unplugging the EV turns Force Charge OFF automatically, so the next plug-in starts from the normal automation state instead of a forgotten override. Acts on the unplug **edge** only, and an `unknown`/`unavailable` status never counts as a disconnect — a sensor glitch must not cancel a Force Charge you are relying on. **Default OFF**; requires the charger status sensor.
@@ -1060,8 +1062,8 @@ Two-phase probing protects against inverter slow-ramp:
10601062

10611063
After a successful probe, Hybrid Mode transitions to **RIDING_EDGE** where it "rides the edge" of `grid_import`:
10621064

1063-
- Grid stays below 50 % of threshold for 60 s → step amperage **up** one level (6 → 8 → 10 → 13 → 16 → 20 → 24 → 32 A)
1064-
- Grid exceeds threshold for `evsc_hybrid_max_import_duration` → step amperage **down** one level
1065+
- Grid stays at or below the threshold for 60 s → step amperage **up** one level (6 → 8 → 10 → 13 → 16 → 20 → 24 → 32 A)
1066+
- Grid exceeds threshold for `evsc_hybrid_max_import_duration` → step amperage **down** one level; the level that tripped the step-down is not re-tried for 10 minutes (anti-oscillation), then the edge is probed again
10651067
- At 6 A and grid still high → stop and treat as a failed probe
10661068

10671069
#### Cost transparency
@@ -1093,6 +1095,7 @@ You will receive **one push notification per day** (filtered by car-owner presen
10931095
| Every probe fails (`COOLDOWN_SHORT` then `COOLDOWN_LONG`) | Raise `evsc_hybrid_max_import_duration` to 90–120 s. Slow-ramp inverters (Solis, Growatt) need more time to react. |
10941096
| Module enters `HARD_EXIT` too often | Your inverter probably has genuine PV constraints (small system, partial shading). Raise `evsc_hybrid_battery_full_threshold` to 100 % so probing only happens when truly full. |
10951097
| RIDING_EDGE oscillates a lot | Raise `evsc_grid_import_threshold` slightly (from 50 W default to 100 W) for more headroom. |
1098+
| RIDING_EDGE stops climbing at a low amperage (e.g. 10 A) | Fixed in v2.13.2. If it still holds, check the diagnostic: `holding below …A import ceiling` means that level really imported from the grid within the last 10 minutes. |
10961099

10971100
#### Interaction with other automations
10981101

‎custom_components/ev_smart_charger/const.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# ========== INTEGRATION METADATA ==========
44
DOMAIN = "ev_smart_charger"
5-
VERSION = "2.13.1"
5+
VERSION = "2.13.2"
66
DEFAULT_NAME = "EV Smart Charger"
77
FRONTEND_URL_BASE = "/api/ev_smart_charger/frontend"
88
FRONTEND_CARD_FILENAME = "ev-smart-charger-dashboard.js"
@@ -506,6 +506,7 @@
506506
HYBRID_COOLDOWN_LONG_SECONDS = 900 # 15 min after N consecutive fails (sliding window)
507507
HYBRID_FAILURE_WINDOW_SECONDS = 1800 # 30 min sliding window for failure counting
508508
HYBRID_HEADROOM_STABLE_SECONDS = 60 # how long grid_import must stay low before stepping up
509+
HYBRID_EDGE_CEILING_SECONDS = 600 # v2.13.2: block re-climbing a level that forced a step-down for 10 min
509510
HYBRID_SUNSET_BUFFER_MIN = 90 # minutes before sunset — do not probe when sun is too low
510511
HYBRID_TRANSIENT_GRACE_SECONDS = 20 # first N seconds of PROBING: ignore grid_import (inverter ramp)
511512
HYBRID_GRID_ENTRY_SMOOTH_SECONDS = 60 # how long grid_import must stay low BEFORE entering PROBING

‎custom_components/ev_smart_charger/hybrid_inverter_mode.py‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
└─ FAIL → stop_charger + COOLDOWN_SHORT, append to failure window
2727
2828
RIDING_EDGE
29-
├─ headroom_ok_since ≥ HYBRID_HEADROOM_STABLE_SECONDS → step amperage up
30-
├─ import_violation_since ≥ max_import_duration → step amperage down
29+
├─ grid ≤ threshold for HYBRID_HEADROOM_STABLE_SECONDS → step amperage up
30+
│ (never onto an active edge ceiling)
31+
├─ import_violation_since ≥ max_import_duration → step amperage down and
32+
│ set an edge ceiling on the tripped level for HYBRID_EDGE_CEILING_SECONDS
3133
├─ at 6A and import persists → STOP + COOLDOWN_SHORT (counts as FAIL)
3234
├─ sustained ≥ HYBRID_RIDING_EDGE_SUCCESS_DURATION → reset failure window
3335
└─ exit condition fails → IDLE (graceful, NOT counted as FAIL)
@@ -84,6 +86,7 @@
8486
HELPER_SOLAR_MAX_AMPERAGE_SUFFIX,
8587
HYBRID_COOLDOWN_LONG_SECONDS,
8688
HYBRID_COOLDOWN_SHORT_SECONDS,
89+
HYBRID_EDGE_CEILING_SECONDS,
8790
HYBRID_FAILURE_WINDOW_SECONDS,
8891
HYBRID_GRID_ENTRY_SMOOTH_SECONDS,
8992
HYBRID_HEADROOM_STABLE_SECONDS,
@@ -206,6 +209,10 @@ def __init__(
206209
# we jump 2 levels at a time to converge faster. Reset on any step-down
207210
# or exit. Tuya is unaffected (its levels are already coarse).
208211
self._consecutive_stepup_count: int = 0
212+
# v2.13.2: amperage level that last forced a step-down; the ramp may not
213+
# climb back onto it until _edge_ceiling_until (anti-oscillation).
214+
self._edge_ceiling_amps: int | None = None
215+
self._edge_ceiling_until: datetime | None = None
209216

210217
# Grid import entry smoothing (so we don't enter PROBING on a 1s dip)
211218
self._grid_import_below_threshold_since: datetime | None = None
@@ -553,6 +560,9 @@ def get_diagnostic_snapshot(self) -> dict[str, Any]:
553560
snapshot["headroom_ok_since"] = self._headroom_ok_since.isoformat()
554561
if self._import_violation_since is not None:
555562
snapshot["import_violation_since"] = self._import_violation_since.isoformat()
563+
if self._edge_ceiling_amps is not None and self._edge_ceiling_until is not None:
564+
snapshot["edge_ceiling_amps"] = self._edge_ceiling_amps
565+
snapshot["edge_ceiling_until"] = self._edge_ceiling_until.isoformat()
556566
if self._notification_sent_on_date is not None:
557567
snapshot["notification_sent_on_date"] = str(self._notification_sent_on_date)
558568
return snapshot
@@ -813,6 +823,9 @@ async def _handle_riding_edge(
813823

814824
# Decrease path
815825
if grid_import > grid_threshold:
826+
# v2.13.2: an over-threshold reading breaks the headroom stability
827+
# window — a step-up must be earned by a fresh stable period.
828+
self._headroom_ok_since = None
816829
if self._import_violation_since is None:
817830
self._import_violation_since = now
818831
violation_elapsed = (now - self._import_violation_since).total_seconds()
@@ -833,6 +846,7 @@ async def _handle_riding_edge(
833846
self._import_violation_since = None
834847
self._headroom_ok_since = None
835848
self._consecutive_stepup_count = 0 # issue #38
849+
self._set_edge_ceiling(current_amps, now)
836850
await self._publish_diagnostic(
837851
reason=f"RIDING_EDGE @ {next_down}A (reduced)"
838852
)
@@ -851,8 +865,16 @@ async def _handle_riding_edge(
851865
)
852866
return
853867

854-
# Reset violation tracking when we're back below threshold
855-
if grid_import <= grid_threshold / 2:
868+
# v2.13.2: at/below the threshold the grid is within budget → ramp path.
869+
# Before v2.13.2 this required grid_import <= threshold/2 and held the
870+
# amperage forever in the (threshold/2, threshold] band. Zero-export
871+
# inverters regulate to a small residual import that grows with the
872+
# load, so the ramp froze as soon as that residual crossed threshold/2
873+
# (~10 A on a typical install) even with kilowatts of headroom left.
874+
# Anti-oscillation now lives in the amperage domain instead: a level
875+
# that tripped a step-down is blocked for HYBRID_EDGE_CEILING_SECONDS
876+
# (see _set_edge_ceiling).
877+
if grid_import <= grid_threshold:
856878
self._import_violation_since = None
857879

858880
# v2.1.0 (issue #29): battery-discharge masking step-down. With grid
@@ -891,6 +913,7 @@ async def _handle_riding_edge(
891913
self._battery_violation_since = None
892914
self._headroom_ok_since = None
893915
self._consecutive_stepup_count = 0 # issue #38
916+
self._set_edge_ceiling(current_amps, now)
894917
await self._publish_diagnostic(
895918
reason=f"RIDING_EDGE @ {next_down}A (reduced, battery)"
896919
)
@@ -917,13 +940,22 @@ async def _handle_riding_edge(
917940
if self._headroom_ok_since is None:
918941
self._headroom_ok_since = now
919942
headroom_elapsed = (now - self._headroom_ok_since).total_seconds()
943+
# v2.13.2: never climb back onto a level that just tripped a
944+
# step-down while its ceiling is still active.
945+
max_amps = solar_max_amperage
946+
ceiling = self._active_edge_ceiling(now)
947+
if ceiling is not None:
948+
max_amps = min(
949+
max_amps,
950+
AmperageCalculator.get_next_level_down(ceiling, self._amp_levels),
951+
)
920952
# Increase path
921953
if (
922954
headroom_elapsed >= HYBRID_HEADROOM_STABLE_SECONDS
923-
and current_amps < solar_max_amperage
955+
and current_amps < max_amps
924956
):
925957
next_up = AmperageCalculator.get_next_level_up(
926-
current_amps, solar_max_amperage, self._amp_levels
958+
current_amps, max_amps, self._amp_levels
927959
)
928960
# issue #38: self-tuning — on a Generic charger (1A levels), once
929961
# we've already stepped up twice in a row on stable headroom, jump
@@ -933,10 +965,10 @@ async def _handle_riding_edge(
933965
self._power_model.charger_model == CHARGER_MODEL_GENERIC
934966
and self._consecutive_stepup_count >= 2
935967
and next_up > current_amps
936-
and next_up < solar_max_amperage
968+
and next_up < max_amps
937969
):
938970
next_up = AmperageCalculator.get_next_level_up(
939-
next_up, solar_max_amperage, self._amp_levels
971+
next_up, max_amps, self._amp_levels
940972
)
941973
if next_up > current_amps:
942974
self.logger.info(
@@ -956,15 +988,38 @@ async def _handle_riding_edge(
956988
reason=f"RIDING_EDGE @ {next_up}A (increased)"
957989
)
958990
return
959-
await self._publish_diagnostic(
960-
reason=f"RIDING_EDGE @ {current_amps}A (stable {headroom_elapsed:.0f}s)"
961-
)
991+
if ceiling is not None and current_amps >= max_amps:
992+
reason = (
993+
f"RIDING_EDGE @ {current_amps}A (holding below {ceiling}A "
994+
"import ceiling)"
995+
)
996+
else:
997+
reason = (
998+
f"RIDING_EDGE @ {current_amps}A (stable {headroom_elapsed:.0f}s)"
999+
)
1000+
await self._publish_diagnostic(reason=reason)
9621001
return
9631002

964-
# Grid in the hysteresis band — hold position
965-
await self._publish_diagnostic(
966-
reason=f"RIDING_EDGE @ {current_amps}A (grid {grid_import:.0f}W, holding)"
967-
)
1003+
def _set_edge_ceiling(self, tripped_amps: int, now: datetime) -> None:
1004+
"""Block climbing back to a level that just forced a step-down.
1005+
1006+
v2.13.2: replaces the old grid-import hysteresis band as the RIDING_EDGE
1007+
anti-oscillation mechanism. The ceiling expires after
1008+
HYBRID_EDGE_CEILING_SECONDS so the edge is re-probed periodically
1009+
(PV availability changes during the day).
1010+
"""
1011+
self._edge_ceiling_amps = tripped_amps
1012+
self._edge_ceiling_until = now + timedelta(seconds=HYBRID_EDGE_CEILING_SECONDS)
1013+
1014+
def _active_edge_ceiling(self, now: datetime) -> int | None:
1015+
"""Return the active ceiling (amps), clearing it once expired."""
1016+
if self._edge_ceiling_amps is None or self._edge_ceiling_until is None:
1017+
return None
1018+
if now >= self._edge_ceiling_until:
1019+
self._edge_ceiling_amps = None
1020+
self._edge_ceiling_until = None
1021+
return None
1022+
return self._edge_ceiling_amps
9681023

9691024
async def _handle_cooldown(self, now: datetime) -> None:
9701025
"""During COOLDOWN_*: wait for the timer to expire."""
@@ -1096,6 +1151,8 @@ async def _transition(self, new_state: str, *, reason: str) -> None:
10961151
self._headroom_ok_since = None
10971152
self._riding_edge_entered_at = None
10981153
self._current_target_amps = 0
1154+
self._edge_ceiling_amps = None
1155+
self._edge_ceiling_until = None
10991156
self.logger.info(
11001157
f"{self.logger.DECISION} Hybrid state: {prev_state} → {new_state} ({reason})"
11011158
)

‎custom_components/ev_smart_charger/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
"iot_class": "local_push",
1212
"issue_tracker": "https://github.com/antbald/ha-ev-smart-charger/issues",
1313
"requirements": [],
14-
"version": "2.13.1"
14+
"version": "2.13.2"
1515
}

0 commit comments

Comments
 (0)