@@ -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 (
0 commit comments