Skip to content

Commit dbcee8b

Browse files
authored
Merge pull request #186 from partach/claude/expand-felicity-card-B7dl4
Claude/expand felicity card b7dl4
2 parents dab1491 + 6169aa0 commit dbcee8b

7 files changed

Lines changed: 579 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ this rule exists to prevent — don't.
7474

7575
### Before Concluding Any Work
7676

77-
- Run `python -m pytest tests/test_ems.py` (must stay green; currently 228).
77+
- Run `python -m pytest tests/test_ems.py` (must stay green; currently 244).
7878
- If you added a setting, update the **Settings Traceability Matrix** below
7979
and confirm it is consumed by the algorithm (no "optimized-out" settings).
8080
- Keep this document in sync with the code. If status changed, update it.
@@ -115,7 +115,7 @@ custom_components/ha_felicity/
115115
└── ha_felicity_ems.js # LitElement EMS dashboard card (1671 lines)
116116
117117
tests/
118-
└── test_ems.py # 234 tests for the pure EMS algorithm
118+
└── test_ems.py # 244 tests for the pure EMS algorithm
119119
```
120120

121121
---
@@ -594,6 +594,38 @@ The validation simulates forward through PV-only overflows (clamping
594594
soc and continuing) instead of breaking on the first PV-caused
595595
violation, which lets it detect phantom charges later in the day.
596596

597+
**Re-shop after overflow drop (greedy from_grid, July 2026)
598+
`_fill_charge_to_deficit`**: the greedy selector picks the cheapest slots
599+
cheapest-first. When the two cheapest slots land *back-to-back* on a small
600+
battery, charging them consecutively overflows capacity, so SOC validation
601+
drops one — and the dropped energy was previously never re-allocated, leaving
602+
the overnight deficit half-covered. Real symptom: a 20 kWh battery under a
603+
40 kWh/day load charged only **1 of 2** needed cheap night slots, then rode
604+
the floor ~9 h earlier than the MILP plan (which placed its second charge
605+
*later*, after consumption had freed headroom). After validation,
606+
`_schedule_from_grid` now re-shops: it walks the remaining slots
607+
cheapest-first and adds each candidate that (a) is priced **≤ the marginal
608+
price the selector already committed to**, (b) survives a fresh validation
609+
against the kept set, and (c) increases delivered charge energy — until the
610+
deficit is covered. Two guards keep it cost-first:
611+
- **Price ceiling** (`max_price` = max price of the selected set): we only
612+
re-shop into a slot as cheap as the ones already chosen. We must NOT reach
613+
for a pricier slot to chase the deficit — bridging an overnight need by
614+
charging a 0.30 evening peak (while the chosen slots were 0.15, or while
615+
cheap 0.05 night grid follows after midnight) costs MORE than drawing the
616+
shortfall from the grid live (round-trip loss on a higher price). Same
617+
no-safety-swap economics the two-day selector applies.
618+
- **PV-aware headroom cap** (`min(deficit, max_battery − current − net_pv)`):
619+
when PV surplus will fill the battery, the target shrinks so we don't
620+
over-charge.
621+
Scoped to greedy `from_grid` and skipped for
622+
`charge_to_full_on_negative_price` (its negative-slot handling is outside the
623+
deficit model). No-op for healthy schedules (validation dropped nothing →
624+
delivered energy already meets the target). Pinned by
625+
`TestGreedyReshopAfterOverflow` (re-shops a cheap slot; never an expensive
626+
one). This closes most of the greedy/MILP gap on undersized-battery /
627+
heavy-load days without touching MILP.
628+
597629
### Arbitrage Price Delta (both mode) — the TRADE TRIGGER
598630

599631
`arbitrage_price_delta` is the explicit minimum buy→sell spread required
@@ -1201,8 +1233,26 @@ Forward-simulates SOC through every slot. Drops violations:
12011233
where `terminal = avg_price × efficiency`.
12021234
- **Price gates**: `arbitrage_price_delta` → charge/discharge UB = 0 for
12031235
slots outside spread. `block_export_on_negative_price` → discharge UB = 0.
1204-
- **Post-solve**: LP allocations ranked by energy, capped by battery
1205-
physics (charge headroom / discharge headroom) to prevent over-scheduling.
1236+
- **Post-solve (cost-ranked discrete extraction)**: the continuous LP is
1237+
collapsed to discrete full-power slots, capped by battery physics (charge /
1238+
discharge headroom) to prevent over-scheduling. The collapse executes the
1239+
most cost-effective subset of the slots the LP used:
1240+
- **Discharge: dearest-first.** When the cap allows fewer sell slots than
1241+
the LP spread energy across (the LP cycles PV — sell early to clear room,
1242+
then sell again at the peak, so the early cheap slot and the peak tie on
1243+
energy), keep the **highest-price** slots. (Ranking by LP energy used to
1244+
keep the cheap early slot over the 0.40 peak — `to_grid` bug #3.)
1245+
- **Charge: cheapest-first, per day.** Cap each day at the energy the LP
1246+
allocated to it (preserving the midnight-reserve **today-first** split — a
1247+
single global cheapest-first cap would let cheap tomorrow slots starve
1248+
today), then within each day pick the **cheapest** slots. A **half-slot
1249+
rule** (only add a slot if the remaining day-target exceeds half its
1250+
energy) stops a pricier slot being pulled in to cover a tiny remainder —
1251+
which is how an expensive reserve-top-off slot used to sneak in (MILP
1252+
charging a 0.30 peak to hit a high self_consumption reserve — bug #2).
1253+
Pinned by `test_milp_sells_evening_peak_not_cheap_early_slot`,
1254+
`test_milp_no_peak_charge_to_hit_reserve`, and the `to_grid_sell_surplus` /
1255+
`self_suff_flat_low_soc` simulator scenarios.
12061256
- Returns `(today_slots, tomorrow_slots)` or `None` → greedy fallback.
12071257

12081258
### 6. Post-Processing (both engines)
@@ -1765,7 +1815,7 @@ in the solver (loads as decision variables, not just overlays).
17651815

17661816
## Testing
17671817

1768-
Tests are in `tests/test_ems.py` (234 tests). They import `ems.py` directly (bypassing HA dependencies) and test the pure scheduling functions.
1818+
Tests are in `tests/test_ems.py` (244 tests). They import `ems.py` directly (bypassing HA dependencies) and test the pure scheduling functions.
17691819

17701820
```bash
17711821
# Run all tests

custom_components/ha_felicity/ems.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,129 @@ def _validate_schedule_soc(
960960
return charge_slots, discharge_slots
961961

962962

963+
def _slot_grid_charge_kwh(
964+
slot_idx: int,
965+
pv_hourly_kwh: dict[int, float] | None,
966+
minutes_per_slot: float,
967+
pv_confidence: float,
968+
energy_per_slot: float,
969+
efficiency: float,
970+
inverter_max_power_kw: float,
971+
safe_power_kw: float,
972+
) -> float:
973+
"""Grid energy (kWh, after charge efficiency) a single charge slot can
974+
actually store, accounting for PV sharing the inverter at that hour.
975+
976+
Mirrors the per-slot charge contribution computed inside
977+
`_validate_schedule_soc`, so callers can size how much a candidate slot
978+
adds without re-running the full SOC simulation.
979+
"""
980+
if inverter_max_power_kw > 0:
981+
hour = int((slot_idx * minutes_per_slot) / 60)
982+
pv_kwh = (pv_hourly_kwh or {}).get(hour, 0.0) * pv_confidence
983+
grid_kw = min(
984+
safe_power_kw or energy_per_slot / (minutes_per_slot / 60.0),
985+
max(0.0, inverter_max_power_kw - pv_kwh),
986+
)
987+
return grid_kw * (minutes_per_slot / 60.0) * efficiency
988+
return energy_per_slot * efficiency
989+
990+
991+
def _fill_charge_to_deficit(
992+
remaining: list[tuple[int, float]],
993+
validated_charge: set[int],
994+
energy_deficit: float,
995+
current_kwh: float,
996+
consumption_per_slot: float,
997+
pv_hourly_kwh: dict[int, float] | None,
998+
minutes_per_slot: float,
999+
pv_confidence: float,
1000+
battery_capacity: float,
1001+
min_kwh: float,
1002+
energy_per_slot: float,
1003+
efficiency: float,
1004+
inverter_max_power_kw: float,
1005+
safe_power_kw: float,
1006+
consumption_hourly_kwh: dict[int, float] | None,
1007+
max_price: float | None = None,
1008+
) -> set[int]:
1009+
"""Re-shop charge energy dropped by SOC validation into later slots.
1010+
1011+
The greedy selector (`select_unified_charge_slots`) picks the cheapest
1012+
slots cheapest-first. When the cheapest slots land back-to-back on a
1013+
small battery, charging them consecutively overflows capacity, so
1014+
`_validate_schedule_soc` drops one — and the dropped energy is otherwise
1015+
never re-allocated, leaving the overnight deficit only partly covered
1016+
(real symptom: a 20 kWh battery under a 40 kWh/day load charged only one
1017+
of two needed cheap slots, then rode the floor ~9 h earlier than the MILP
1018+
plan, which placed its second charge *later*, after consumption had freed
1019+
headroom).
1020+
1021+
This walks the remaining slots cheapest-first and adds each candidate that
1022+
(a) is priced at or below ``max_price`` (the marginal price the selector
1023+
already committed to), (b) survives a fresh validation against the
1024+
already-kept set, and (c) increases the delivered charge energy. Adding a
1025+
slot *after* prior drain is exactly what lets the second charge fit.
1026+
1027+
The ``max_price`` ceiling is the cost-discipline guard: we only re-shop the
1028+
dropped energy into a slot as cheap as the ones already chosen. We must
1029+
NOT reach for a more expensive slot to chase the deficit — if the only
1030+
feasible later slot is pricier (e.g. a 0.30 evening peak while the chosen
1031+
slots were 0.15, or while cheap 0.05 night grid follows after midnight),
1032+
bridging the overnight need by charging that expensive slot costs MORE than
1033+
just drawing the shortfall from the grid live (round-trip losses on a
1034+
higher price). This is the same no-safety-swap economics the two-day
1035+
selector already applies. Cheapest-first + the ceiling keep us cost-first;
1036+
we stop the moment the deficit is covered, so the battery is never charged
1037+
beyond what overnight survival needs. In the common case where validation
1038+
dropped nothing, the delivered energy already meets the deficit and this
1039+
returns immediately — a no-op for healthy schedules.
1040+
"""
1041+
1042+
def delivered(slots: set[int]) -> float:
1043+
return sum(
1044+
_slot_grid_charge_kwh(
1045+
s, pv_hourly_kwh, minutes_per_slot, pv_confidence,
1046+
energy_per_slot, efficiency, inverter_max_power_kw,
1047+
safe_power_kw,
1048+
)
1049+
for s in slots
1050+
)
1051+
1052+
validated = set(validated_charge)
1053+
got = delivered(validated)
1054+
if got >= energy_deficit - 0.01:
1055+
return validated
1056+
1057+
attempted = set(validated)
1058+
pool = sorted(
1059+
(
1060+
(idx, price) for idx, price in remaining
1061+
if price is not None and idx not in attempted
1062+
and (max_price is None or price <= max_price + 0.0001)
1063+
),
1064+
key=lambda ip: ip[1],
1065+
)
1066+
for idx, _price in pool:
1067+
if got >= energy_deficit - 0.01:
1068+
break
1069+
attempted.add(idx)
1070+
trial, _ = _validate_schedule_soc(
1071+
remaining, validated | {idx}, set(),
1072+
current_kwh, consumption_per_slot, pv_hourly_kwh,
1073+
minutes_per_slot, pv_confidence, battery_capacity, min_kwh,
1074+
energy_per_slot, efficiency,
1075+
inverter_max_power_kw=inverter_max_power_kw,
1076+
safe_power_kw=safe_power_kw,
1077+
consumption_hourly_kwh=consumption_hourly_kwh,
1078+
)
1079+
new_got = delivered(trial)
1080+
if new_got > got + 0.01:
1081+
validated = trial
1082+
got = new_got
1083+
return validated
1084+
1085+
9631086
def _select_discharges_for_pv_headroom(
9641087
remaining: list[tuple[int, float]],
9651088
current_kwh: float,
@@ -2257,7 +2380,42 @@ def _schedule_from_grid(
22572380
safe_power_kw=config.safe_power_kw,
22582381
keep_all_negative_charges=config.charge_to_full_on_negative_price,
22592382
)
2260-
selected = [(idx, p) for idx, p in selected if idx in validated_charge]
2383+
2384+
# Re-shop any charge energy that validation dropped for overflow into
2385+
# later slots where prior consumption has freed headroom, so the overnight
2386+
# deficit is actually covered (see _fill_charge_to_deficit). Skipped for
2387+
# charge_to_full_on_negative_price, whose negative-slot handling and
2388+
# PV-curtailment trade-off are intentionally outside the deficit model.
2389+
if not config.charge_to_full_on_negative_price:
2390+
# Bound the re-shop by the SAME PV-aware headroom the selector uses
2391+
# (max_battery − current − net_pv_surplus). This is the cost
2392+
# discriminator: when PV surplus will fill the battery (net_pv > 0),
2393+
# headroom is small, so we never reach for an expensive evening slot
2394+
# to chase the deficit — the original cheap slot + solar already
2395+
# cover it (a small battery at midday under good PV must NOT buy
2396+
# peak-priced evening grid just to top off; that violates
2397+
# cost-first). When PV can't fill the battery (net_pv ≈ 0, heavy
2398+
# load), headroom is large and the dropped cheap slot is re-shopped.
2399+
fill_headroom = max(0.0, max_battery_kwh - current_kwh - max(0.0, net_pv))
2400+
fill_target = min(energy_deficit, fill_headroom)
2401+
# Marginal price the selector already committed to: never re-shop the
2402+
# dropped energy into a pricier slot (see _fill_charge_to_deficit).
2403+
fill_max_price = max((p for _, p in selected if p is not None), default=None)
2404+
validated_charge = _fill_charge_to_deficit(
2405+
remaining, validated_charge, fill_target,
2406+
current_kwh, consumption_per_slot, state.pv_hourly_kwh,
2407+
minutes_per_slot, pv_confidence, config.battery_capacity_kwh,
2408+
min_kwh, energy_per_slot, config.efficiency,
2409+
config.inverter_max_power_kw, config.safe_power_kw,
2410+
state.consumption_hourly_kwh,
2411+
max_price=fill_max_price,
2412+
)
2413+
2414+
price_of_remaining = {idx: p for idx, p in remaining}
2415+
selected = [
2416+
(idx, price_of_remaining.get(idx, 0.0)) for idx in validated_charge
2417+
]
2418+
selected.sort(key=lambda ip: ip[0])
22612419

22622420
# discharge_to_make_room_for_negative_price: in from_grid mode we
22632421
# normally don't discharge. When this opt-in is enabled, schedule

custom_components/ha_felicity/milp.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,18 @@ def _add_day(prices: list[float | None], pv_hourly: dict[int, float],
345345
if dv > slot_discharge_cap * MIN_FRAC:
346346
discharge_candidates.append((k, h, dv))
347347

348-
# Sort by LP-allocated energy descending — highest-conviction slots first.
349-
charge_candidates.sort(key=lambda x: -x[2])
350-
discharge_candidates.sort(key=lambda x: -x[2])
348+
# Rank for the capped, discrete, full-power execution. The LP spreads
349+
# energy across more slots than the inverter can run at full power, so we
350+
# take a *subset* up to the physical headroom (below). Among the slots the
351+
# LP chose to use, execute the most COST-EFFECTIVE subset: most-expensive
352+
# slots to SELL (charge is ranked per-day in the extraction below, to
353+
# respect the today/tomorrow split). Ranking discharge by LP-allocated
354+
# energy instead (the old behaviour) could keep an early CHEAP-priced sell
355+
# over the evening PEAK when both got similar energy — the LP cycles PV
356+
# (sell early to make room, then sell again at the peak), so both tie on
357+
# energy and the cheaper one used to win. Price is the primary key; LP
358+
# energy is the tiebreaker (conviction).
359+
discharge_candidates.sort(key=lambda x: (-x[1]["price"], -x[2]))
351360

352361
# Compute energy caps from battery physics. The LP's continuous
353362
# totals include marginal fractional allocations the inverter can't
@@ -377,16 +386,34 @@ def _add_day(prices: list[float | None], pv_hourly: dict[int, float],
377386
today_scheduled: dict[int, str] = {}
378387
tomorrow_scheduled: dict[int, str] = {}
379388

380-
accum = 0.0
381-
for k, h, kw in charge_candidates:
382-
if accum >= charge_target_kwh:
383-
break
384-
slot_energy = h["charge_cap"] or safe_kwh
385-
accum += slot_energy * eff # effective stored energy at full power
386-
if h["day"] == "today":
387-
today_scheduled[h["slot"]] = "charge"
388-
else:
389-
tomorrow_scheduled[h["slot"]] = "charge"
389+
# --- Charge execution: per day, cheapest-first ---
390+
# Collapse the LP's continuous charge plan to discrete full-power slots.
391+
# Cap EACH DAY at the effective energy the LP allocated to it, so the
392+
# today/tomorrow split the solver chose is preserved: the midnight-reserve
393+
# constraint decides how much MUST charge today (self-sufficiency
394+
# "today-first") versus deferring to a cheaper tomorrow — a single global
395+
# cap + cheapest-first would let cheap tomorrow slots starve today.
396+
# WITHIN each day, execute the CHEAPEST slots, and never pull in a pricier
397+
# slot just to cover a sub-half-slot remainder — that half-slot rule is
398+
# what stops an expensive reserve-top-off slot from sneaking in (#2: a
399+
# 0.30 evening slot used to be added to "finish" a reserve the two cheap
400+
# 0.15 slots had effectively already met).
401+
for day, sched in (("today", today_scheduled),
402+
("tomorrow", tomorrow_scheduled)):
403+
cands = sorted(
404+
((h, cv) for (k, h, cv) in charge_candidates if h["day"] == day),
405+
key=lambda x: (x[0]["price"], -x[1]),
406+
)
407+
target = eff * sum(cv for _, cv in cands)
408+
if day == "today":
409+
target = min(target, charge_target_kwh)
410+
accum = 0.0
411+
for h, cv in cands:
412+
slot_energy = (h["charge_cap"] or safe_kwh) * eff
413+
if target - accum <= slot_energy * 0.5:
414+
break # remaining need < half a slot — don't add a pricier one
415+
accum += slot_energy
416+
sched[h["slot"]] = "charge"
390417

391418
accum = 0.0
392419
for k, h, kw in discharge_candidates:

docs/EMS_LOGIC.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,42 @@ scenario and/or `tests/test_ems.py`.
5252
8. **Manual slot picks are grid-mode-aware.** from_grid → any picked slot is a
5353
charge slot (even above threshold); to_grid → any is a sell slot; both →
5454
threshold decides.
55+
9. **Greedy re-shops charge energy dropped for overflow (from_grid).** When SOC
56+
validation drops a back-to-back charge slot that overflowed a small battery,
57+
`_schedule_from_grid` re-shops the dropped energy into a LATER slot where
58+
prior drain freed headroom — but only at a price **≤ the marginal price the
59+
selector already committed to**, and bounded by PV-aware headroom. So a
60+
heavy-load / undersized-battery day charges the second *cheap* slot (closing
61+
the greedy/MILP gap — both reach the same planned kWh), while a PV-fed small
62+
battery never buys an expensive evening peak to chase the deficit
63+
(cost-first / no-safety-swap: drawing the shortfall live from cheap night
64+
grid beats a lossy round-trip at a higher price). Pinned by
65+
`TestGreedyReshopAfterOverflow` + the `cons_heavy_flat` /
66+
`self_suff_flat_low_soc` simulator scenarios.
67+
68+
10. **MILP discrete-extraction is cost-ranked (July 2026).** The MILP solves a
69+
continuous LP, then collapses it to discrete full-power slots capped by
70+
battery physics. That collapse now executes the most cost-effective
71+
subset:
72+
- **Sells dearest-first.** When the cap allows fewer sell slots than the LP
73+
spread energy across (the LP cycles PV — sell early to clear room, then
74+
sell again at the peak — so the early cheap slot and the peak tie on
75+
energy), the extraction keeps the **highest-price** slots. Fixes MILP
76+
selling a cheap 0.18 slot instead of the 0.40 evening peak
77+
(`to_grid_sell_surplus`).
78+
- **Charges cheapest-first, per day.** Charge selection is capped **per day**
79+
at the energy the LP allocated to that day (preserving the midnight-reserve
80+
"today-first" split — a single global cheapest-first cap would let cheap
81+
tomorrow slots starve today), and within each day picks the cheapest slots.
82+
A **half-slot rule** stops it pulling in a pricier slot to cover a
83+
sub-half-slot remainder — which is how an expensive reserve-top-off slot
84+
used to sneak in. Fixes MILP charging a 0.30 evening peak to hit a high
85+
self_consumption reserve (`self_suff_flat_low_soc`); it now charges the
86+
0.15 slots and accepts the cheaper round-trip, like greedy.
87+
Pinned by `test_milp_sells_evening_peak_not_cheap_early_slot`,
88+
`test_milp_no_peak_charge_to_hit_reserve`, and the two simulator scenarios
89+
(whose expectations now assert "no peak charge" / "sells the peak" for BOTH
90+
engines).
5591

5692
---
5793

0 commit comments

Comments
 (0)