Skip to content

Commit 54988ea

Browse files
authored
Merge pull request #120 from partach/claude/expand-felicity-card-B7dl4
Drop phantom tomorrow charge slot when battery is already full
2 parents 0164939 + 71f0105 commit 54988ea

3 files changed

Lines changed: 176 additions & 22 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,15 @@ negative-price income is pure profit. When PV is insufficient to cause
234234
overflow on its own, negative-price charge slots are still pruned to
235235
prevent forced grid export at penalty rates.
236236

237+
**Phantom-charge detection**: when a charge slot is scheduled at a
238+
moment the battery is already at capacity (`soc_before >= capacity - 0.01`),
239+
the inverter physically cannot store the energy (BMS rejects). These
240+
slots are dropped regardless of price — even negative-price slots,
241+
because the income doesn't materialise if no grid energy is drawn.
242+
The validation simulates forward through PV-only overflows (clamping
243+
soc and continuing) instead of breaking on the first PV-caused
244+
violation, which lets it detect phantom charges later in the day.
245+
237246
### Arbitrage Price Delta (both mode)
238247

239248
When `arbitrage_price_delta > 0` and `max_remaining_price - min_remaining_price >= delta`:

custom_components/ha_felicity/ems.py

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -490,14 +490,31 @@ def _validate_schedule_soc(
490490
surplus = pv_per_slot - cons
491491
if surplus > 0:
492492
pv_surplus_total += surplus
493-
pv_fills_battery = pv_surplus_total >= (battery_capacity - current_kwh) * 0.9
493+
# pv_surplus_total is logged when a negative-price slot is kept due
494+
# to PV-caused overflow. The actual exemption check is per-slot
495+
# (violation_pv_caused) below.
496+
497+
# PV-overflow exemption only applies when the battery has real room
498+
# to fill. When current_kwh is already at/near capacity, the exemption
499+
# would preserve negative-price slots that the inverter can't actually
500+
# execute (BMS rejects charging a full battery), producing phantom
501+
# schedule entries.
502+
pv_fills_battery = (
503+
current_kwh < battery_capacity * 0.95
504+
and pv_surplus_total >= (battery_capacity - current_kwh) * 0.9
505+
)
494506

495507
max_iterations = len(charge_slots) + len(discharge_slots) + 1
496508

497509
for _ in range(max_iterations):
498510
violation_slot: int | None = None
499511
violation_type: str | None = None # "low" or "high"
500-
discharge_seen = False # track if a discharge happened before violation
512+
# Whether the charge action at the violation slot is wasted: when
513+
# the battery is already at capacity entering a charge slot, the
514+
# inverter can't physically store any of the grid energy. Such
515+
# phantom slots must be dropped regardless of price.
516+
violation_charge_wasted = False
517+
discharge_seen = False
501518

502519
soc = current_kwh
503520
for slot_idx, _ in remaining:
@@ -510,39 +527,61 @@ def _validate_schedule_soc(
510527
else:
511528
cons = consumption_per_slot
512529
delta = pv_per_slot - cons
530+
charge_contribution = 0.0
513531
if slot_idx in charge_slots:
514532
if inverter_max_power_kw > 0:
515533
grid_kw = min(safe_power_kw or energy_per_slot / (minutes_per_slot / 60.0),
516534
max(0.0, inverter_max_power_kw - pv_kwh * pv_confidence))
517-
delta += grid_kw * (minutes_per_slot / 60.0) * efficiency
535+
charge_contribution = grid_kw * (minutes_per_slot / 60.0) * efficiency
518536
else:
519-
delta += energy_per_slot * efficiency
537+
charge_contribution = energy_per_slot * efficiency
538+
delta += charge_contribution
520539
if slot_idx in discharge_slots:
521540
delta -= energy_per_slot
522541
discharge_seen = True
523542

524-
soc = soc + delta
543+
soc_before = soc
544+
soc_ideal = soc + delta
525545

526-
# Check bounds BEFORE clamping — charging a full battery wastes
527-
# energy, discharging an empty one is impossible.
528-
if soc < min_kwh - 0.01:
529-
# If no discharge happened before this point, the low SOC
530-
# is inherent (battery starts below reserve/min) — not
531-
# caused by any scheduled discharge. Clamp and continue
532-
# rather than pruning unrelated future discharge slots.
546+
# Low-bound check: SOC dipping below min is always a violation.
547+
if soc_ideal < min_kwh - 0.01:
533548
if not discharge_seen:
534-
soc = max(0.0, min(battery_capacity, soc))
549+
soc = max(0.0, min(battery_capacity, soc_ideal))
535550
continue
536551
violation_slot = slot_idx
537552
violation_type = "low"
538553
break
539-
if soc > battery_capacity + 0.01:
554+
if soc_ideal > battery_capacity + 0.01:
555+
# Determine causality. If PV alone (no charge action)
556+
# would also overflow at this slot, the natural BMS
557+
# behaviour is to spill the excess — clamp soc and keep
558+
# simulating to detect any genuine charge-caused issues
559+
# at later slots (e.g., a phantom charge during clamped
560+
# hours that would otherwise be missed).
561+
soc_no_charge = soc_before + (delta - charge_contribution)
562+
pv_alone_overflows = soc_no_charge > battery_capacity + 0.01
563+
if pv_alone_overflows:
564+
# Phantom charge: action present on already-full battery.
565+
# Drop regardless of price — the energy can't land.
566+
if (charge_contribution > 0
567+
and soc_before >= battery_capacity - 0.01):
568+
violation_charge_wasted = True
569+
violation_slot = slot_idx
570+
violation_type = "high"
571+
break
572+
# PV-only spill: clamp and continue simulating.
573+
soc = battery_capacity
574+
continue
575+
# Charge action contributes to / causes overflow.
576+
if (charge_contribution > 0
577+
and soc_before >= battery_capacity - 0.01):
578+
violation_charge_wasted = True
540579
violation_slot = slot_idx
541580
violation_type = "high"
542581
break
543582

544-
# Clamp to physical limits for subsequent slot calculations
545-
soc = max(0.0, min(battery_capacity, soc))
583+
# Clamp for next iteration
584+
soc = max(0.0, min(battery_capacity, soc_ideal))
546585

547586
if violation_slot is None:
548587
break # Schedule is valid
@@ -580,10 +619,14 @@ def _validate_schedule_soc(
580619
]
581620
if not candidates:
582621
# Only negative-price slots remain. If PV alone would
583-
# fill the battery, the overflow is PV-caused — pruning
584-
# negative-price slots won't prevent it, and the income
585-
# from charging at negative prices is pure profit.
586-
if pv_fills_battery:
622+
# fill the battery (and the battery has room to fill),
623+
# the overflow is PV-caused — pruning negative-price
624+
# slots won't prevent it, and the income from charging
625+
# at negative prices is pure profit.
626+
# EXCEPTION: when the violation slot's charge action is
627+
# wasted (battery was already at capacity entering it),
628+
# the negative-price slot is phantom — drop it anyway.
629+
if pv_fills_battery and not violation_charge_wasted:
587630
_LOGGER.debug(
588631
"SOC validation: keeping negative-price charge slots — "
589632
"PV surplus (%.1f kWh) fills battery anyway",
@@ -859,6 +902,34 @@ def _compute_tomorrow_schedule(
859902
for h in daylight_hours:
860903
pv_hourly_tomorrow[h] = pv_per_daylight_hour
861904

905+
# Validate tomorrow's charge slots: drop any that would overflow.
906+
# Without this, a negative or near-zero-price slot picked by the
907+
# unified selector ends up scheduled even when SOC is already pegged
908+
# at 100% from PV — a phantom "charge" the inverter can't execute.
909+
if charge_indices:
910+
consumption_per_slot_t = config.consumption_est_kwh / num_slots
911+
validated_charge_t, _ = _validate_schedule_soc(
912+
remaining, set(charge_indices), set(),
913+
midnight_kwh, consumption_per_slot_t,
914+
pv_hourly_tomorrow, minutes_per_slot, 1.0,
915+
config.battery_capacity_kwh, min_kwh,
916+
energy_per_slot, config.efficiency,
917+
consumption_hourly_kwh=state.consumption_hourly_kwh,
918+
inverter_max_power_kw=config.inverter_max_power_kw,
919+
safe_power_kw=config.safe_power_kw,
920+
)
921+
dropped_t = [i for i in charge_indices if i not in validated_charge_t]
922+
for idx in dropped_t:
923+
scheduled.pop(idx, None)
924+
charge_indices.discard(idx)
925+
if dropped_t:
926+
_LOGGER.info(
927+
"Tomorrow schedule: dropped %d charge slot(s) that would "
928+
"overflow battery (midnight_kwh=%.1f, capacity=%.1f): %s",
929+
len(dropped_t), midnight_kwh, config.battery_capacity_kwh,
930+
sorted(dropped_t),
931+
)
932+
862933
# Sell slots (to_grid or both mode)
863934
if config.grid_mode in ("to_grid", "both"):
864935
reserve_kwh = calculate_self_consumption_reserve(

tests/test_ems.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2733,13 +2733,15 @@ def test_trex10_from_grid_cloudy_day(self):
27332733
def test_trex5_to_grid_sunny_day(self):
27342734
"""TREX-5, 10kWh battery, to_grid only, sunny day.
27352735
2736-
Expected: sell slots at peak prices, no charge slots.
2736+
Expected: sell slots at peak prices, no charge slots, SOC stays
2737+
above reserve target (consumption tuned so reserve doesn't block
2738+
the evening sell).
27372739
"""
27382740
config = default_config(
27392741
grid_mode="to_grid",
27402742
battery_capacity_kwh=10.0,
27412743
battery_discharge_min_pct=20.0,
2742-
consumption_est_kwh=8.0,
2744+
consumption_est_kwh=4.0, # smaller reserve to leave room for sell
27432745
safe_power_kw=3.0,
27442746
inverter_max_power_kw=5.0,
27452747
)
@@ -3488,3 +3490,75 @@ def test_fallback_used_when_forecast_missing(self):
34883490
f"With PV fallback ({fb_charges} charges), should plan no more "
34893491
f"grid charging than without ({no_fb_charges})"
34903492
)
3493+
3494+
3495+
class TestTomorrowFullBatteryNoPhantomCharge:
3496+
"""When battery is already full and tomorrow's PV will keep it there,
3497+
no charge slots should be scheduled — the inverter can't charge a
3498+
full battery. Regression test for the negative-price slot getting
3499+
scheduled at hour 16 when SOC was 100% all day."""
3500+
3501+
def test_no_tomorrow_charge_when_battery_full_with_pv(self):
3502+
"""Battery starts full, PV tomorrow keeps it full → no charge slots."""
3503+
config = default_config(
3504+
grid_mode="from_grid",
3505+
battery_capacity_kwh=60.0,
3506+
battery_discharge_min_pct=35.0,
3507+
battery_charge_max_pct=100.0,
3508+
consumption_est_kwh=30.9,
3509+
safe_power_kw=8.0,
3510+
)
3511+
# One slot has a slightly negative price tomorrow (would otherwise
3512+
# be auto-selected by negative-slot pickup)
3513+
prices_today = [0.20] * 24
3514+
prices_tomorrow = [0.20] * 16 + [-0.01] + [0.20] * 7
3515+
state = default_state(
3516+
battery_soc_pct=100.0,
3517+
slot_prices_today=prices_today,
3518+
slot_prices_tomorrow=prices_tomorrow,
3519+
pv_hourly_kwh=make_pv_hourly(48.0),
3520+
pv_forecast_remaining=10.2,
3521+
pv_forecast_today=48.0,
3522+
pv_forecast_tomorrow=40.0,
3523+
pv_actual_today_kwh=37.9,
3524+
current_hour=15,
3525+
)
3526+
result = calculate_schedule(config, state)
3527+
tomorrow_charges = sum(
3528+
1 for v in result.tomorrow_scheduled_slots.values() if v == "charge"
3529+
)
3530+
assert tomorrow_charges == 0, (
3531+
f"Battery full + PV keeps it full → no tomorrow charge slots, "
3532+
f"got {tomorrow_charges}: {result.tomorrow_scheduled_slots}"
3533+
)
3534+
3535+
def test_negative_charge_kept_when_battery_has_room(self):
3536+
"""The fix shouldn't break the legitimate case: when battery has
3537+
actual room to fill and PV would overflow it, negative-price slots
3538+
are still kept (the income is pure profit)."""
3539+
config = default_config(
3540+
grid_mode="from_grid",
3541+
battery_capacity_kwh=60.0,
3542+
battery_discharge_min_pct=20.0,
3543+
consumption_est_kwh=10.0,
3544+
safe_power_kw=5.0,
3545+
inverter_max_power_kw=10.0,
3546+
)
3547+
prices = [0.05] * 7 + [-0.46, -0.30, -0.20] + [0.05] * 4 + [0.10] * 4 + [0.15] * 6
3548+
state = default_state(
3549+
battery_soc_pct=29.0, # plenty of headroom
3550+
slot_prices_today=prices,
3551+
pv_hourly_kwh=make_pv_hourly(65.0),
3552+
pv_forecast_remaining=64.7,
3553+
pv_forecast_today=65.0,
3554+
pv_actual_today_kwh=0.3,
3555+
current_hour=7,
3556+
)
3557+
result = calculate_schedule(config, state)
3558+
charges = {k for k, v in result.scheduled_slots.items() if v == "charge"}
3559+
neg_slots = {i for i, p in enumerate(prices) if p < 0 and i >= 7}
3560+
neg_charged = charges & neg_slots
3561+
assert len(neg_charged) == len(neg_slots), (
3562+
f"With headroom, all negative-price slots should still charge: "
3563+
f"got {len(neg_charged)} of {len(neg_slots)}"
3564+
)

0 commit comments

Comments
 (0)