Skip to content

Commit ee080a9

Browse files
authored
Merge pull request #181 from partach/claude/expand-felicity-card-B7dl4
Claude/expand felicity card b7dl4
2 parents 9abfe22 + 2583389 commit ee080a9

11 files changed

Lines changed: 761 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@ memory or assumptions from training — the facts and status are HERE. If
1414
something you intend to do contradicts this document, stop and reconcile it
1515
first.
1616

17+
### The Documentation Set — keep ALL of it current (do not let it float)
18+
19+
There are TWO authoritative docs and they must BOTH be read and kept in sync:
20+
21+
| Doc | Role | Update when |
22+
|---|---|---|
23+
| **CLAUDE.md** (this file) | Architecture, status, every fix's rationale, known issues, settings matrix | every change |
24+
| **docs/EMS_LOGIC.md** | The behavioural SPECIFICATION — what the EMS should do per mode/knob, traced from inputs to action | every change that alters behaviour |
25+
26+
A past session updated only CLAUDE.md and let `docs/EMS_LOGIC.md` go stale —
27+
that is the "floating basis" failure. **Before concluding any work, update
28+
BOTH** (CLAUDE.md for the what/why/status, EMS_LOGIC.md for the behavioural
29+
contract). `EMS.md` and the root `EMS_LOGIC.md` are older analysis docs — do
30+
not treat them as current.
31+
32+
### Validate behaviour with the simulator — don't assert from memory
33+
34+
`tools/ems_simulator.py` runs `ems.calculate_schedule` on a library of named
35+
scenarios (`tools/scenarios.py`) for BOTH engines, checks expectations, and
36+
renders charts. When you change scheduling behaviour: add/adjust a scenario
37+
that encodes the intended outcome, run `python tools/ems_simulator.py`, and
38+
confirm it's green before claiming a fix works. Reproduce customer reports as
39+
scenarios so they become permanent, readable regression tests.
40+
41+
### Engine default = GREEDY (decision, June 2026)
42+
43+
`scheduler_engine` defaults to **greedy** (multi-month track record, no solver
44+
dependency). **MILP is opt-in.** A prior session flipped the default to MILP;
45+
it was reverted after fact-checking showed the recent customer-reported bugs
46+
were MILP-specific or shared-reserve (not greedy scheduling), plus MILP carries
47+
a pulp/CBC dependency and was not yet validated for determinism. Do NOT flip
48+
the default back to MILP without (a) the simulator harness showing MILP is
49+
deterministic and correct across all knob scenarios, and (b) explicit user
50+
agreement. Greedy's two-day reconstruction is less powerful on cross-day
51+
arbitrage, but it is the proven, dependency-free path.
52+
1753
### The One Rule That Keeps Breaking — Single Point of Truth
1854

1955
**`ems.py` is the SINGLE SOURCE OF TRUTH for all scheduling logic. It must

custom_components/ha_felicity/__init__.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
216216
"flexible_load_1_voltage": 230,
217217
"flexible_load_1_default_current": 16,
218218
"ev_charge_strategy": "smart",
219-
"scheduler_engine": "milp",
219+
"scheduler_engine": "greedy",
220220
"flexible_load_2_enabled": "off",
221221
"flexible_load_2_name": "",
222222
"flexible_load_2_switch_entity": "",
@@ -233,17 +233,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
233233
if key not in updated_options:
234234
updated_options[key] = default
235235

236-
# One-time default-engine bump: MILP is now the default (it's the robust,
237-
# joint 2-day optimiser; greedy's two-day reconstruction is fragile). Most
238-
# existing installs have "greedy" only because that was the OLD default —
239-
# not a deliberate choice. Bump them once. This is SAFE: when CBC/pulp is
240-
# unavailable the MILP auto-disables to greedy (see milp._MILP_DISABLED), so
241-
# no install can break. The marker means we only bump once, so a user who
242-
# deliberately re-selects greedy afterwards is respected.
243-
if (updated_options.get("scheduler_engine") == "greedy"
244-
and not updated_options.get("_scheduler_default_milp_done")):
245-
updated_options["scheduler_engine"] = "milp"
246-
updated_options["_scheduler_default_milp_done"] = True
236+
# Engine default = GREEDY (reverted). Greedy has a multi-month track
237+
# record and no solver dependency; MILP is opt-in until the day-simulator
238+
# harness validates it for determinism across all knobs. Undo the earlier
239+
# auto-bump for installs that were AUTO-switched to MILP (marker set) —
240+
# surgical and once, so a user who DELIBERATELY selected MILP (no marker)
241+
# is never touched.
242+
if (updated_options.get("_scheduler_default_milp_done")
243+
and not updated_options.get("_scheduler_milp_reverted")):
244+
if updated_options.get("scheduler_engine") == "milp":
245+
updated_options["scheduler_engine"] = "greedy"
246+
updated_options["_scheduler_milp_reverted"] = True
247247

248248
if updated_options != options:
249249
hass.config_entries.async_update_entry(entry, options=updated_options)

custom_components/ha_felicity/config_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def _get_default_options(self) -> dict:
108108
"flexible_load_1_voltage": 230,
109109
"flexible_load_1_default_current": 16,
110110
"ev_charge_strategy": "smart",
111-
"scheduler_engine": "milp",
111+
"scheduler_engine": "greedy",
112112
"flexible_load_2_enabled": "off",
113113
"flexible_load_2_name": "",
114114
"flexible_load_2_switch_entity": "",

custom_components/ha_felicity/coordinator.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,71 @@ def _calculate_net_pv_surplus(self, remaining_slots: list[tuple[int, float]],
679679
previous_pv_confidence=self._last_pv_confidence,
680680
)
681681

682+
def _build_manual_schedule(self) -> None:
683+
"""Build `scheduled_slots` from the price threshold (manual mode).
684+
685+
Manual price mode is a simple rule — NOT the optimizer:
686+
- from_grid / both: charge every remaining slot whose price is
687+
BELOW the threshold.
688+
- to_grid / both: sell every remaining slot whose price is
689+
ABOVE the threshold.
690+
This keeps the card's displayed schedule consistent with what the
691+
manual-mode `_determine_energy_state` actually executes, and ensures
692+
we never show a charge slot above the threshold (the optimizer's plan
693+
is irrelevant in manual mode). Past slots are left unscheduled.
694+
"""
695+
opts = self.config_entry.options
696+
grid_mode = opts.get("grid_mode", "off")
697+
prices = self.slot_prices_today
698+
if grid_mode == "off" or not prices or self.price_threshold is None:
699+
self.scheduled_slots = {}
700+
self._tomorrow_scheduled_slots = {}
701+
self.schedule_status = "manual"
702+
self.schedule_reason = "Manual mode — follows price threshold"
703+
return
704+
705+
current_slot = self._current_slot_index() or 0
706+
allow_charge = grid_mode in ("from_grid", "both")
707+
allow_sell = grid_mode in ("to_grid", "both")
708+
threshold = self.price_threshold
709+
710+
sched: dict[int, str] = {}
711+
for i in range(current_slot, len(prices)):
712+
price = prices[i]
713+
if price is None:
714+
continue
715+
if allow_charge and price < threshold:
716+
sched[i] = "charge"
717+
elif allow_sell and price > threshold:
718+
sched[i] = "discharge"
719+
720+
self.scheduled_slots = sched
721+
# Tomorrow: same threshold rule across all of tomorrow's slots.
722+
tmr: dict[int, str] = {}
723+
if self.slot_prices_tomorrow:
724+
for i, price in enumerate(self.slot_prices_tomorrow):
725+
if price is None:
726+
continue
727+
if allow_charge and price < threshold:
728+
tmr[i] = "charge"
729+
elif allow_sell and price > threshold:
730+
tmr[i] = "discharge"
731+
self._tomorrow_scheduled_slots = tmr
732+
733+
# Clear the optimizer's SOC trajectory so the card doesn't draw a
734+
# stale auto-mode line; it falls back to its own client-side
735+
# trajectory built from the threshold schedule above.
736+
self._backend_soc_trajectory = []
737+
self._backend_soc_trajectory_tomorrow = []
738+
739+
n_charge = sum(1 for v in sched.values() if v == "charge")
740+
n_sell = sum(1 for v in sched.values() if v == "discharge")
741+
self.schedule_status = "manual"
742+
self.schedule_reason = (
743+
f"Manual mode: {n_charge} charge / {n_sell} sell slot(s) "
744+
f"vs threshold {threshold:.3f}"
745+
)
746+
682747
async def _calculate_schedule(self, battery_soc: float | None) -> None:
683748
"""Calculate optimal charge/discharge schedule.
684749
@@ -759,7 +824,7 @@ async def _calculate_schedule(self, battery_soc: float | None) -> None:
759824
)).lower() in ("on", "true", "1"),
760825
flexible_loads=self._build_flex_load_configs(),
761826
ev_charge_strategy=str(opts.get("ev_charge_strategy", "smart")),
762-
scheduler_engine=str(opts.get("scheduler_engine", "milp")),
827+
scheduler_engine=str(opts.get("scheduler_engine", "greedy")),
763828
)
764829

765830
# What did the previous schedule predict the SOC would be at this slot?
@@ -849,7 +914,7 @@ async def _calculate_schedule(self, battery_soc: float | None) -> None:
849914
json.dumps(self.slot_overrides, sort_keys=True) if self.slot_overrides else "",
850915
safe_power_kw,
851916
opts.get("ev_charge_strategy", "smart"),
852-
opts.get("scheduler_engine", "milp"),
917+
opts.get("scheduler_engine", "greedy"),
853918
))
854919
if (input_hash == self._last_schedule_input_hash
855920
and current_slot_idx == self._last_schedule_slot_idx):
@@ -2471,6 +2536,15 @@ def get_attr(names):
24712536
await self._calculate_schedule(battery_soc)
24722537
# Schedule may have updated self.price_threshold
24732538
new_data["price_threshold"] = self.price_threshold
2539+
else:
2540+
# Manual mode: the displayed schedule must follow
2541+
# the PRICE THRESHOLD, not a stale optimizer plan.
2542+
# _calculate_schedule only runs in auto, so
2543+
# scheduled_slots would otherwise keep showing the
2544+
# last MILP/greedy plan — including charge slots
2545+
# ABOVE the threshold, which manual mode must
2546+
# never do. Rebuild it from the threshold here.
2547+
self._build_manual_schedule()
24742548

24752549
# Always calculate available info (visible in both modes)
24762550
self._calculate_available_info(battery_soc)

custom_components/ha_felicity/ems.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class EMSConfig:
117117
# "milp" (the solver in milp.py). When "milp", calculate_schedule tries
118118
# the MILP first and silently falls back to greedy on any failure
119119
# (pulp missing, infeasible, timeout).
120-
scheduler_engine: str = "milp"
120+
scheduler_engine: str = "greedy"
121121
# NOTE: battery State of Health (SOH) is applied by the coordinator
122122
# before constructing this config — it scales battery_capacity_kwh
123123
# by the SOH factor. ems.py treats the capacity as already-effective.
@@ -1875,11 +1875,22 @@ def calculate_schedule(config: EMSConfig, state: EMSState) -> ScheduleResult:
18751875
if (not state.pv_hourly_kwh
18761876
and forecast_total and forecast_total > 0
18771877
and forecast_remaining and forecast_remaining > 0):
1878+
# Preserve/synthesize TOMORROW's hourly PV too. The old rebuild
1879+
# didn't pass pv_hourly_kwh_tomorrow at all → it was silently dropped
1880+
# to None, so when today's hourly was absent (forecast gives daily
1881+
# totals only) the MILP saw tomorrow PV = 0 and planned the whole next
1882+
# day with NO sun: the SOC trajectory declined all day and it barely
1883+
# charged (real customer report). Mirror the today synthesis.
1884+
pv_hourly_kwh_tomorrow = state.pv_hourly_kwh_tomorrow
1885+
if (not pv_hourly_kwh_tomorrow
1886+
and state.pv_forecast_tomorrow and state.pv_forecast_tomorrow > 0):
1887+
pv_hourly_kwh_tomorrow = _synthesize_pv_hourly(state.pv_forecast_tomorrow)
18781888
state = EMSState(
18791889
battery_soc_pct=state.battery_soc_pct,
18801890
slot_prices_today=state.slot_prices_today,
18811891
slot_prices_tomorrow=state.slot_prices_tomorrow,
18821892
pv_hourly_kwh=_synthesize_pv_hourly(forecast_total),
1893+
pv_hourly_kwh_tomorrow=pv_hourly_kwh_tomorrow,
18831894
pv_forecast_remaining=forecast_remaining,
18841895
pv_forecast_today=forecast_total,
18851896
pv_forecast_tomorrow=state.pv_forecast_tomorrow,

custom_components/ha_felicity/select.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ async def async_setup_entry(
120120
coordinator=coordinator,
121121
entry=entry,
122122
option_key="scheduler_engine",
123-
select_options=["milp", "greedy"],
123+
select_options=["greedy", "milp"],
124124
name="Scheduler Engine",
125125
icon="mdi:function-variant",
126126
entity_category=EntityCategory.CONFIG,

docs/EMS_LOGIC.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,47 @@
22

33
This document traces the complete decision path of the Felicity EMS, from raw inputs to inverter action, for each mode.
44

5+
> **This is the behavioural SPECIFICATION.** It must be kept in sync with the
6+
> code on every behaviour change (alongside CLAUDE.md). The body below predates
7+
> June 2026; the **Recent behavioural decisions** section is the current
8+
> authority where it conflicts with older text. Validate with
9+
> `tools/ems_simulator.py`.
10+
11+
---
12+
13+
## Recent behavioural decisions (June–July 2026) — current authority
14+
15+
These refine/override the older sections below. Each is pinned by a simulator
16+
scenario and/or `tests/test_ems.py`.
17+
18+
1. **Engine default = greedy; MILP opt-in.** Greedy has a multi-month track
19+
record and no solver dependency. MILP is the joint 2-day optimiser but is
20+
validated per-scenario before it can become default again.
21+
2. **Reserve is time-aware (from_grid + MILP all modes).** Past sunset the
22+
overnight reserve covers only the REMAINING hours to sunrise, not the full
23+
night — so a high-consumption house isn't forced to charge at peak evening
24+
prices to "maintain" a full-night reserve the battery should be discharging.
25+
3. **Self-consumption boost dropped at night.** The ×1.25 boost (hold extra PV
26+
energy for self-use) is a daytime concept; at night the reserve is bare
27+
survival so the battery rides down and refills from tomorrow's PV.
28+
4. **Overnight need is profile-aware.** The reserve SUMS the 7-day *hourly*
29+
consumption profile over the night hours (not flat daily/24). Critical for
30+
daytime-heavy loads (EVs): high daily average but low night use → small
31+
reserve → no phantom evening charging. Falls back to flat with no profile.
32+
The hourly profile is resolved via the entity registry (exact unique_id),
33+
not a guessed entity_id.
34+
5. **MILP is provably feasible.** Reserve constraints are soft (shortfall slack
35+
+ penalty) and the SOC dynamics carry a per-slot grid-passthrough slack, so
36+
the LP never returns Infeasible (which used to drop ~24% of runs to greedy).
37+
6. **Manual price mode is a threshold rule, not the optimizer.** `scheduled_slots`
38+
in manual mode is rebuilt from the threshold (from_grid/both charge BELOW it,
39+
to_grid/both sell ABOVE it) — never the stale optimizer plan.
40+
7. **No charge deferral in the coordinator.** Every scheduled charge slot
41+
executes; the cheapest-slot decision lives entirely in `ems.py`.
42+
8. **Manual slot picks are grid-mode-aware.** from_grid → any picked slot is a
43+
charge slot (even above threshold); to_grid → any is a sell slot; both →
44+
threshold decides.
45+
546
---
647

748
## 1. User Configuration: The Settings That Shape Every Decision

tests/test_ems.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5058,10 +5058,11 @@ def test_sell_coverage_adds_today_charge_slots(self):
50585058
class TestMILPScheduler:
50595059
"""Tests for the optional MILP scheduler (milp.py)."""
50605060

5061-
def test_default_engine_is_milp(self):
5062-
"""MILP is now the default engine (robust joint 2-day optimiser)."""
5061+
def test_default_engine_is_greedy(self):
5062+
"""Greedy is the default engine (proven track record, no solver dep).
5063+
MILP is opt-in until the day-simulator harness validates it."""
50635064
config = EMSConfig(grid_mode="from_grid", battery_capacity_kwh=10)
5064-
assert config.scheduler_engine == "milp"
5065+
assert config.scheduler_engine == "greedy"
50655066

50665067
def test_milp_never_infeasible_extreme_drain(self):
50675068
"""MILP must stay feasible (not fall back) even when consumption would

tools/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# EMS day-simulator / scenario harness
2+
3+
A standalone way to **see and confirm what the scheduling algorithm actually
4+
does** — for both engines (greedy and MILP) — without Home Assistant. It runs
5+
`ems.calculate_schedule` on a library of realistic scenarios, prints a report,
6+
checks per-scenario expectations, and (optionally) renders a chart per scenario.
7+
8+
## Run it (Windows / macOS / Linux)
9+
10+
```bat
11+
cd ha_felicity
12+
python -m pip install pulp matplotlib
13+
python tools\ems_simulator.py
14+
```
15+
16+
- `pulp` enables the **MILP** engine (without it, only greedy runs).
17+
- `matplotlib` enables the **charts** (without it you still get the full text report).
18+
19+
Charts are written to `tools\sim_output\<scenario>.png` — one image per
20+
scenario with two stacked panels (greedy on top, MILP below): price bars
21+
coloured **green = charge / orange = sell / grey = idle**, with the projected
22+
**SOC %** line and the **reserve** line overlaid.
23+
24+
### Useful flags
25+
26+
```bat
27+
python tools\ems_simulator.py --name self_suff_daytime_ev :: one scenario
28+
python tools\ems_simulator.py --engine greedy :: one engine
29+
python tools\ems_simulator.py --no-plot :: text only
30+
```
31+
32+
The process exits **0** when every scenario expectation passes, **1** if any
33+
fail — so it can gate a release.
34+
35+
## What's covered
36+
37+
`tools/scenarios.py` holds the scenario library. Each scenario exercises
38+
specific knobs and asserts the intended outcome. The current set covers:
39+
40+
| Scenario | Knobs exercised | What it pins |
41+
|---|---|---|
42+
| `save_money_cheap_night` | from_grid, cost, reserve_target_pct | charges only the cheapest slots |
43+
| `self_suff_daytime_ev` | self_consumption, hourly profile, evening | **customer case**: no peak evening charge at 80% SOC |
44+
| `self_suff_flat_low_soc` | self_consumption, low SOC midday | charges to cover the deficit |
45+
| `trader_arbitrage` | both, cost, wide spread | buys cheap, sells above buy price |
46+
| `to_grid_sell_surplus` | to_grid, high SOC, PV | never charges from grid |
47+
| `negative_prices_charge_to_full` | charge_to_full_on_negative_price | greedy grabs all p<0; MILP charges p<0 |
48+
| `low_soc_urgent_recovery` | from_grid, SOC < min | forces immediate charging |
49+
| `tomorrow_pv_daily_only` | daily-only forecast, two-day | tomorrow's PV is synthesised (not zero) |
50+
| `longevity_cycle_cost` | both, longevity | wear floor suppresses marginal trades |
51+
| `arbitrage_delta_gate` | both, arbitrage_price_delta | no sells below the required spread |
52+
53+
## Add your own
54+
55+
Append a dict to `SCENARIOS` in `tools/scenarios.py`:
56+
57+
```python
58+
{
59+
"name": "my_case",
60+
"desc": "what it checks",
61+
"config": dict(grid_mode="from_grid", optimization_priority="self_consumption",
62+
battery_capacity_kwh=48.0, ...all the knobs...),
63+
"state": dict(battery_soc_pct=80.0, slot_prices_today=[...],
64+
pv_hourly_kwh={...}, consumption_hourly_kwh={...},
65+
current_hour=22, current_minute=30),
66+
"expect": lambda r, s: (r["charge_slots"] == [], "should not charge"),
67+
}
68+
```
69+
70+
Reproduce a customer screenshot by transcribing its prices / SOC / time / knobs
71+
into a scenario — then the expected behaviour becomes a permanent, runnable
72+
regression test that anyone can read.

0 commit comments

Comments
 (0)