Skip to content

Commit de29384

Browse files
authored
Merge pull request #118 from partach/claude/expand-felicity-card-B7dl4
Claude/expand felicity card b7dl4
2 parents 061531e + c64f226 commit de29384

4 files changed

Lines changed: 129 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,12 @@ After selecting slots, simulates battery forward through every slot. Drops slots
219219
- Charge pushing SOC > capacity → drop most expensive non-negative charge first, then negative-price charges if needed
220220
- Discharge pulling SOC < minimum → drop least profitable discharge
221221

222-
Negative-price slots are no longer fully exempt from overflow pruning.
223-
When PV production combined with grid charging would overfill the battery,
224-
negative-price charge slots are dropped to prevent forced grid export at
225-
penalty rates (inverter cannot disconnect PV panels).
222+
Negative-price slots are preserved when PV alone would fill the battery
223+
(surplus ≥ 90% of remaining capacity). In that case the overflow is
224+
PV-caused — pruning negative-price slots won't prevent it, and the
225+
negative-price income is pure profit. When PV is insufficient to cause
226+
overflow on its own, negative-price charge slots are still pruned to
227+
prevent forced grid export at penalty rates.
226228

227229
### Arbitrage Price Delta (both mode)
228230

@@ -237,8 +239,8 @@ Prevents over-scheduling when PV will fill the battery:
237239
```python
238240
headroom = max(0, max_battery_kwh - current_kwh - net_pv_surplus)
239241
max_today_slots = floor(headroom / effective_per_slot)
240-
# Negative-price slots pass through here (profitable to consume),
241-
# but _validate_schedule_soc prunes any that would cause overflow.
242+
# Negative-price slots pass through here (profitable to consume).
243+
# SOC validation prunes only when PV alone wouldn't fill the battery.
242244
```
243245

244246
---
@@ -473,7 +475,7 @@ discharge combined).
473475

474476
## Testing
475477

476-
Tests are in `tests/test_ems.py` (130 tests). They import `ems.py` directly (bypassing HA dependencies) and test the pure scheduling functions.
478+
Tests are in `tests/test_ems.py` (134 tests). They import `ems.py` directly (bypassing HA dependencies) and test the pure scheduling functions.
477479

478480
```bash
479481
# Run all tests

custom_components/ha_felicity/ems.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,23 @@ def _validate_schedule_soc(
431431
# Build price lookup from remaining
432432
price_of: dict[int, float] = {idx: price for idx, price in remaining}
433433

434+
# Check if PV alone would fill the battery (net surplus > available space).
435+
# When true, overflow is PV-caused — pruning negative-price charge slots
436+
# won't prevent it, and the negative-price income is pure profit.
437+
pv_surplus_total = 0.0
438+
for slot_idx, _ in remaining:
439+
hour = int((slot_idx * minutes_per_slot) / 60)
440+
pv_kwh = (pv_hourly_kwh or {}).get(hour, 0.0) * pv_confidence
441+
pv_per_slot = pv_kwh * (minutes_per_slot / 60.0)
442+
if consumption_hourly_kwh and hour in consumption_hourly_kwh:
443+
cons = consumption_hourly_kwh[hour] * (minutes_per_slot / 60.0)
444+
else:
445+
cons = consumption_per_slot
446+
surplus = pv_per_slot - cons
447+
if surplus > 0:
448+
pv_surplus_total += surplus
449+
pv_fills_battery = pv_surplus_total >= (battery_capacity - current_kwh) * 0.9
450+
434451
max_iterations = len(charge_slots) + len(discharge_slots) + 1
435452

436453
for _ in range(max_iterations):
@@ -507,9 +524,7 @@ def _validate_schedule_soc(
507524
else:
508525
# Remove the most expensive charge at or before violation.
509526
# Prefer dropping non-negative slots first; fall back to
510-
# negative-price slots if those are the only ones left —
511-
# a negative-price slot that overflows the battery still
512-
# causes forced PV export at penalty rates.
527+
# negative-price slots only when PV alone wouldn't overflow.
513528
candidates = [
514529
s for s in charge_slots
515530
if s <= violation_slot and price_of.get(s, 0.0) >= 0
@@ -520,6 +535,17 @@ def _validate_schedule_soc(
520535
if price_of.get(s, 0.0) >= 0
521536
]
522537
if not candidates:
538+
# Only negative-price slots remain. If PV alone would
539+
# fill the battery, the overflow is PV-caused — pruning
540+
# negative-price slots won't prevent it, and the income
541+
# from charging at negative prices is pure profit.
542+
if pv_fills_battery:
543+
_LOGGER.debug(
544+
"SOC validation: keeping negative-price charge slots — "
545+
"PV surplus (%.1f kWh) fills battery anyway",
546+
pv_surplus_total,
547+
)
548+
break
523549
candidates = [
524550
s for s in charge_slots
525551
if s <= violation_slot

custom_components/ha_felicity/frontend/ha_felicity_ems.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -692,12 +692,15 @@ class FelicityEMSCard extends LitElement {
692692
const chartH = h - marginTop - marginBottom;
693693
const barW = Math.max(1, (w - marginLeft - marginRight) / numSlots);
694694

695-
// Find price range
695+
// Find price range — add padding below minimum so bars at the lowest
696+
// price still have visible height (otherwise they're clipped to 0px).
696697
const prices = displayData.map((s) => s.price).filter((p) => p != null);
697698
const actualMinPrice = prices.length ? Math.min(...prices) : 0;
698699
const actualMaxPrice = prices.length ? Math.max(...prices) : 0;
699-
const minPrice = Math.min(...prices, 0);
700+
const rawMin = Math.min(...prices, 0);
700701
const maxPrice = Math.max(...prices, 0.01);
702+
const rawRange = maxPrice - rawMin || 0.01;
703+
const minPrice = rawMin - rawRange * 0.05;
701704
const range = maxPrice - minPrice || 0.01;
702705

703706
// Current time marker (only for today view)

tests/test_ems.py

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -759,8 +759,9 @@ def test_negative_prices_from_grid(self):
759759
charged_neg = sum(1 for s in charge_slots if prices[s] < 0)
760760
assert charged_neg > 0
761761

762-
def test_negative_prices_skipped_when_pv_fills_battery(self):
763-
"""Negative-price charging skipped when PV would overfill the battery."""
762+
def test_negative_prices_kept_when_pv_fills_battery(self):
763+
"""Negative-price charging preserved when PV fills battery — the
764+
overflow is PV-caused, so negative-price income is pure profit."""
764765
config = default_config()
765766
prices = make_prices(24, pattern="negative")
766767
state = default_state(
@@ -773,7 +774,10 @@ def test_negative_prices_skipped_when_pv_fills_battery(self):
773774
)
774775
result = calculate_schedule(config, state)
775776
charge_slots = [k for k, v in result.scheduled_slots.items() if v == "charge"]
776-
assert len(charge_slots) == 0
777+
neg_slots = [s for s in charge_slots if prices[s] < 0]
778+
assert len(neg_slots) > 0, (
779+
"Negative-price slots should be kept when PV fills battery anyway"
780+
)
777781

778782
def test_yesterday_deficit_carryover(self):
779783
"""Yesterday's deficit increases today's charging."""
@@ -1077,8 +1081,9 @@ def test_scenario_negative_prices_bonus_charge(self):
10771081
neg_slots = [s for s in charge_slots if prices[s] < 0]
10781082
assert len(neg_slots) >= 1
10791083

1080-
def test_scenario_negative_prices_skipped_when_battery_full_with_pv(self):
1081-
"""Negative prices skipped when PV + high SOC leaves no room."""
1084+
def test_scenario_negative_prices_kept_when_pv_fills_battery(self):
1085+
"""Negative-price slots kept when PV alone fills battery — overflow
1086+
is PV-caused, negative-price income is pure profit."""
10821087
config = default_config(consumption_est_kwh=20.0)
10831088
prices = [0.25] * 10 + [-0.05, -0.03, -0.01] + [0.25] * 11
10841089
state = default_state(
@@ -1092,8 +1097,9 @@ def test_scenario_negative_prices_skipped_when_battery_full_with_pv(self):
10921097
result = calculate_schedule(config, state)
10931098
charge_slots = [k for k, v in result.scheduled_slots.items() if v == "charge"]
10941099
neg_slots = [s for s in charge_slots if prices[s] < 0]
1095-
# With 10 kWh battery at 70% and 35 kWh PV, no room for grid charging
1096-
assert len(neg_slots) == 0
1100+
assert len(neg_slots) >= 1, (
1101+
"Negative-price slots should be kept when PV fills battery anyway"
1102+
)
10971103

10981104
def test_scenario_both_mode_negative_prices_with_headroom(self):
10991105
"""Both mode charges at negative prices when battery has room."""
@@ -1115,8 +1121,9 @@ def test_scenario_both_mode_negative_prices_with_headroom(self):
11151121
neg_slots = [s for s in charge_slots if prices[s] < 0]
11161122
assert len(neg_slots) >= 1
11171123

1118-
def test_scenario_both_mode_negative_prices_limited_by_pv(self):
1119-
"""Both mode limits negative-price charging when PV would overflow battery."""
1124+
def test_scenario_both_mode_negative_prices_kept_when_pv_fills(self):
1125+
"""Both mode keeps negative-price charging when PV fills battery —
1126+
overflow is PV-caused, negative-price income is profit."""
11201127
config = default_config(
11211128
grid_mode="both", consumption_est_kwh=10.0,
11221129
)
@@ -1132,8 +1139,10 @@ def test_scenario_both_mode_negative_prices_limited_by_pv(self):
11321139
)
11331140
result = calculate_schedule(config, state)
11341141
charge_slots = [k for k, v in result.scheduled_slots.items() if v == "charge"]
1135-
# Battery at 80% with 40 kWh PV on 10 kWh battery: almost no room
1136-
assert len(charge_slots) <= 2
1142+
neg_slots = [s for s in charge_slots if prices[s] < 0]
1143+
assert len(neg_slots) >= 1, (
1144+
"Negative-price slots should be kept when PV fills battery anyway"
1145+
)
11371146

11381147

11391148
# ---------------------------------------------------------------------------
@@ -3024,6 +3033,72 @@ def test_sell_slots_preserved_when_pv_recovers_soc(self):
30243033
f"SOC at slot {i} is {soc}%, below min {config.battery_discharge_min_pct}%"
30253034
)
30263035

3036+
def test_negative_charge_preserved_when_pv_fills_battery(self):
3037+
"""When PV alone would fill the battery, negative-price charge slots
3038+
should NOT be pruned — the overflow is PV-caused, and charging at
3039+
negative prices is pure profit."""
3040+
config = default_config(
3041+
grid_mode="from_grid",
3042+
battery_capacity_kwh=60.0,
3043+
battery_discharge_min_pct=20.0,
3044+
consumption_est_kwh=10.0,
3045+
safe_power_kw=5.0,
3046+
inverter_max_power_kw=10.0,
3047+
)
3048+
# Negative prices at hours 7-10, positive rest of day
3049+
prices = [0.05] * 7 + [-0.46, -0.30, -0.20] + [0.05] * 4 + [0.10] * 4 + [0.15] * 6
3050+
state = default_state(
3051+
battery_soc_pct=29.0,
3052+
slot_prices_today=prices,
3053+
pv_hourly_kwh=make_pv_hourly(65.0),
3054+
pv_forecast_remaining=64.7,
3055+
pv_forecast_today=65.0,
3056+
pv_actual_today_kwh=0.3,
3057+
current_hour=7,
3058+
)
3059+
result = calculate_schedule(config, state)
3060+
charges = {k for k, v in result.scheduled_slots.items() if v == "charge"}
3061+
neg_slots = {i for i, p in enumerate(prices) if p < 0 and i >= 7}
3062+
neg_charged = charges & neg_slots
3063+
assert len(neg_charged) == len(neg_slots), (
3064+
f"All {len(neg_slots)} negative-price slots should be charged "
3065+
f"(PV fills battery anyway), but only {len(neg_charged)} are: "
3066+
f"charged={sorted(charges)}, neg={sorted(neg_slots)}"
3067+
)
3068+
3069+
def test_negative_charge_still_pruned_when_pv_insufficient(self):
3070+
"""When PV doesn't fill the battery, negative-price charge slots
3071+
that would cause overflow should still be pruned."""
3072+
config = default_config(
3073+
grid_mode="from_grid",
3074+
battery_capacity_kwh=60.0,
3075+
battery_discharge_min_pct=20.0,
3076+
consumption_est_kwh=10.0,
3077+
safe_power_kw=5.0,
3078+
inverter_max_power_kw=10.0,
3079+
)
3080+
# Battery nearly full, little PV, negative prices
3081+
prices = [0.05] * 7 + [-0.10, -0.05, -0.02] + [0.05] * 14
3082+
state = default_state(
3083+
battery_soc_pct=92.0,
3084+
slot_prices_today=prices,
3085+
pv_hourly_kwh=make_pv_hourly(5.0),
3086+
pv_forecast_remaining=3.0,
3087+
pv_forecast_today=5.0,
3088+
pv_actual_today_kwh=2.0,
3089+
current_hour=7,
3090+
)
3091+
result = calculate_schedule(config, state)
3092+
charges = {k for k, v in result.scheduled_slots.items() if v == "charge"}
3093+
neg_slots = {i for i, p in enumerate(prices) if p < 0 and i >= 7}
3094+
# At 92% of 60 = 55.2 kWh, capacity = 60, only 4.8 kWh headroom
3095+
# With 5 kW * 0.9 eff = 4.5 kWh per slot, only 1 slot fits.
3096+
# PV doesn't fill battery → overflow pruning should still apply.
3097+
assert len(charges & neg_slots) < len(neg_slots), (
3098+
f"Not all negative slots should charge when battery is nearly full "
3099+
f"and PV insufficient — overflow pruning should apply"
3100+
)
3101+
30273102
def test_sell_still_pruned_when_discharge_causes_violation(self):
30283103
"""When a sell actually causes SOC to drop below min, it should
30293104
still be pruned (the fix only affects inherent low SOC)."""

0 commit comments

Comments
 (0)