Skip to content

Commit 60cbaa8

Browse files
committed
Add negative-price strategies + switch config sliders to input boxes
Two new orthogonal EMS config flags (off by default, compose with all grid modes): - charge_to_full_on_negative_price: schedule every p<0 slot even when SOC validation would otherwise prune it (user accepts forced PV curtailment in exchange for guaranteed revenue at p<0). - discharge_to_make_room_for_negative_price: pre-emptively discharge in earlier positive-price slots so the battery has headroom to absorb PV during p<0 windows (avoiding forced grid export at penalty rates). Works in from_grid mode too, which normally never discharges. Both flags compose: pre-discharge clears space, then charge_to_full fills the battery from grid at p<0. Exposed as off/on select entities in the HA UI. Also: HA_FelicityInternalNumber defaults to NumberMode.BOX so users can type precise values for fractional / fine-grained settings (efficiency_factor, arbitrage_price_delta, reserve_target_pct, etc.). Sliders were hard to read and set precisely. Pass mode=SLIDER explicitly to opt back in. 10 new ems tests cover the strategies in from_grid, both, and composed forms. All 158 ems tests pass.
1 parent a597e0e commit 60cbaa8

7 files changed

Lines changed: 692 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ This prevents the SOC prediction from assuming unrealistic charge rates
143143
| battery_cycle_cost_eur_kwh | 0.0 | Wear cost; added to min sell price |
144144
| optimization_priority | "cost" | cost / longevity / self_consumption |
145145
| block_export_on_negative_price | True | Skip sell scheduling at p < 0 |
146+
| charge_to_full_on_negative_price | False | Schedule every p<0 slot (revenue at p<0); accepts forced PV curtailment |
147+
| discharge_to_make_room_for_negative_price | False | Pre-emptively discharge before p<0 PV windows so PV can fill the battery |
146148

147149
The coordinator applies a SOH factor to nominal `battery_capacity_kwh`
148150
before constructing `EMSConfig` — ems.py treats the capacity as
@@ -260,6 +262,49 @@ max_today_slots = floor(headroom / effective_per_slot)
260262
# SOC validation prunes only when PV alone wouldn't fill the battery.
261263
```
262264

265+
### Negative-Price Strategies (charge_to_full / discharge_to_make_room)
266+
267+
Two orthogonal opt-in flags that change how negative-price slots are
268+
handled. Both are off by default and compose with all grid modes
269+
(from_grid, to_grid, both).
270+
271+
**`charge_to_full_on_negative_price`** — acts *during* p<0 slots
272+
- In `_schedule_from_grid` / `_schedule_both`, after the normal
273+
cheapest-slot selection, every negative-price slot in the remaining
274+
window is added to the charge set (deduplicated).
275+
- `_validate_schedule_soc` is called with `keep_all_negative_charges=True`.
276+
When set, negative-price slots are exempt from overflow pruning even
277+
when PV alone wouldn't fill the battery (the legacy `pv_fills_battery`
278+
exemption is broadened to "all negatives").
279+
- Phantom-charge slots (battery already at capacity entering the slot)
280+
are kept too — the inverter may try to charge and the BMS will gate
281+
it. No harm; the schedule reflects user intent.
282+
- Trade-off: the user accepts some forced PV curtailment in exchange
283+
for guaranteed revenue at every p<0 slot.
284+
285+
**`discharge_to_make_room_for_negative_price`** — acts *before* p<0 slots
286+
- New helper `_select_discharges_for_pv_headroom` runs after charge
287+
selection. Walks the SOC trajectory forward; whenever a negative-
288+
price slot with PV surplus would overflow the battery, schedules
289+
discharge in the *most expensive* earlier positive-price slot to
290+
create headroom.
291+
- Validity: SOC must never drop below the absolute `min_kwh` floor
292+
(hardware safety), and end-of-day SOC must remain >= reserve_target
293+
(overnight coverage). Temporary dips below reserve during the day
294+
are allowed — the negative-window PV will refill the battery.
295+
- Works in `from_grid` mode (which normally never discharges) as well
296+
as `to_grid` / `both`. In `both` mode, the make-room discharges
297+
are merged with the regular sell-side selection (sells take
298+
precedence on conflicting slots).
299+
300+
**Composition**: when both flags are on, make-room discharges are
301+
scheduled before negative windows, then charge slots fire during the
302+
negatives. Net effect: maximum profit on negative-price days
303+
(discharge at peak + buy at p<0 + PV fills the cleared battery).
304+
305+
Exposed as off/on select entities (`HA_FelicitySpecialModeSelect`)
306+
in the UI.
307+
263308
---
264309

265310
## Safe Power Management (coordinator.py:1077-1193)
@@ -331,6 +376,15 @@ Fetches `energy_state` history from HA API (throttled 60s), shows what actually
331376
| max_amperage_per_phase | number | 10-63 A | 16 | Grid current limit |
332377
| voltage_level | number | 48-60 V | 58 | Charge voltage setpoint |
333378
| discharge_min_voltage | number | 48-55 V | 50 | Discharge voltage floor |
379+
| charge_to_full_on_negative_price | select | off/on | off | Charge at every p<0 slot (revenue) |
380+
| discharge_to_make_room_for_negative_price | select | off/on | off | Pre-discharge before p<0 PV windows |
381+
382+
All `HA_FelicityInternalNumber` configuration entities render as
383+
input boxes (`NumberMode.BOX`) so users can type precise values.
384+
Sliders are awkward for fractional / fine-grained settings like
385+
`efficiency_factor` (step 0.01) or `arbitrage_price_delta` (step
386+
0.01 €/kWh). Pass `mode=NumberMode.SLIDER` to the constructor for
387+
entities that benefit from scrubbing.
334388

335389
### Key Sensor Entities
336390

custom_components/ha_felicity/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
191191
"daily_consumption_estimate": 10,
192192
"price_mode": "manual",
193193
"safe_power_management": "auto",
194+
"charge_to_full_on_negative_price": "off",
195+
"discharge_to_make_room_for_negative_price": "off",
194196
CONF_REGISTER_SET: DEFAULT_REGISTER_SET,
195197
"update_interval": 10,
196198
}

custom_components/ha_felicity/coordinator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,12 @@ def _calculate_schedule(self, battery_soc: float | None) -> None:
676676
block_export_on_negative_price=opts.get(
677677
"block_export_on_negative_price", True
678678
),
679+
charge_to_full_on_negative_price=opts.get(
680+
"charge_to_full_on_negative_price", "off"
681+
) == "on",
682+
discharge_to_make_room_for_negative_price=opts.get(
683+
"discharge_to_make_room_for_negative_price", "off"
684+
) == "on",
679685
)
680686

681687
state = ems_module.EMSState(

0 commit comments

Comments
 (0)