Skip to content

Commit 453c1ff

Browse files
committed
Backend as single source of truth for tomorrow schedule + SOC trajectory
- Frontend uses backend tomorrow schedule and SOC trajectory when no overrides active, eliminating divergence from client-side simulation - Fix tomorrow sell slots: pass synthesized PV hourly to SOC validation so discharge slots aren't incorrectly pruned during daylight hours - Add 13 integration tests covering real-world TREX-5/10/25/50 scenarios and tomorrow schedule computation (130 tests total) https://claude.ai/code/session_01P9LAQ5ET6SU9dLWULxv2uF
1 parent 6ddbac9 commit 453c1ff

6 files changed

Lines changed: 608 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ custom_components/ha_felicity/
3737
└── ha_felicity_ems.js # LitElement EMS dashboard card (1671 lines)
3838
3939
tests/
40-
└── test_ems.py # 115 tests for the pure EMS algorithm
40+
└── test_ems.py # 130 tests for the pure EMS algorithm
4141
```
4242

4343
---
@@ -278,6 +278,14 @@ Also detects external changes (user adjusting via inverter app).
278278
### Client-Side Simulation
279279
Mirrors coordinator logic for instant preview when dragging sliders. Uses `sim_params` from `schedule_status` sensor attributes.
280280

281+
**Backend as single source of truth**: For both today and tomorrow views,
282+
when no slider or slot overrides are active, the card uses
283+
backend-provided `slot_schedule` / `slot_schedule_tomorrow` (with actions)
284+
and `backend_soc_trajectory` / `backend_soc_trajectory_tomorrow` for the
285+
SOC line. Client-side simulation (`_simulateSchedule`,
286+
`_simulateScheduleTomorrow`, `_computeSocTrajectory`) only runs when the
287+
user is actively previewing via sliders or manual slot clicks.
288+
281289
### Past Slot History
282290
Fetches `energy_state` history from HA API (throttled 60s), shows what actually happened vs what was planned.
283291

@@ -292,7 +300,7 @@ Fetches `energy_state` history from HA API (throttled 60s), shows what actually
292300
| grid_mode | select | off/from_grid/to_grid/both | off | Main EMS switch |
293301
| price_mode | select | manual/auto | manual | Price threshold mode |
294302
| safe_power_management | select | auto/on/off | auto | Amperage protection |
295-
| power_level | number | 1-10 kW | 5 | Charge/discharge power |
303+
| power_level | number | 1-N kW (model max) | 5 | Charge/discharge power |
296304
| price_threshold_level | number | 1-10 | 5 | Manual price level |
297305
| battery_charge_max_level | number | 30-100% | 100 | Max SOC for charging |
298306
| battery_discharge_min_level | number | 10-70% | 20 | Min SOC for discharging |
@@ -465,7 +473,7 @@ discharge combined).
465473

466474
## Testing
467475

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

470478
```bash
471479
# Run all tests
@@ -485,6 +493,10 @@ python -m pytest tests/test_ems.py::TestSolarProtection -v
485493
- Reserve target override
486494
- Arbitrage price delta
487495
- Slot granularity (24/48/96 slots)
496+
- Inverter max power cap (per-model)
497+
- Both-mode sell with charge energy (low PV confidence)
498+
- Integration tests: real-world TREX-5/10/25/50 scenarios
499+
- Tomorrow schedule computation and SOC trajectory
488500

489501
**Not tested**: coordinator.py runtime logic (requires HA mocking). This is a gap — when the coordinator diverges from ems.py, tests won't catch it.
490502

custom_components/ha_felicity/coordinator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ def __init__(
114114
self.tomorrow_planned_slots: int = 0
115115
self.tomorrow_planned_kwh: float = 0.0
116116
self._backend_soc_trajectory: list[float] = []
117+
self._tomorrow_scheduled_slots: dict[int, str] = {}
118+
self._backend_soc_trajectory_tomorrow: list[float] = []
117119

118120
# Always-visible slot info (regardless of price_mode)
119121
self.available_slots_at_threshold: int = 0
@@ -644,6 +646,8 @@ def _calculate_schedule(self, battery_soc: float | None) -> None:
644646
self.tomorrow_planned_kwh = result.tomorrow_planned_kwh
645647
self.schedule_status = result.status
646648
self._backend_soc_trajectory = result.soc_trajectory
649+
self._tomorrow_scheduled_slots = result.tomorrow_scheduled_slots
650+
self._backend_soc_trajectory_tomorrow = result.tomorrow_soc_trajectory
647651

648652
if result.price_threshold is not None:
649653
self.price_threshold = result.price_threshold

custom_components/ha_felicity/ems.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ class ScheduleResult:
6262
tomorrow_precharge: float = 0.0
6363
status: str = "off"
6464
soc_trajectory: list[float] = field(default_factory=list)
65+
tomorrow_scheduled_slots: dict[int, str] = field(default_factory=dict)
66+
tomorrow_soc_trajectory: list[float] = field(default_factory=list)
6567

6668

6769
@dataclass
@@ -714,6 +716,156 @@ def select_unified_charge_slots(
714716
return today_result, tomorrow_result, tomorrow_charge_kwh
715717

716718

719+
def _compute_tomorrow_schedule(
720+
config: EMSConfig,
721+
state: EMSState,
722+
today_result: ScheduleResult,
723+
today_soc_trajectory: list[float],
724+
) -> tuple[dict[int, str], list[float]]:
725+
"""Compute tomorrow's charge/discharge schedule and SOC trajectory.
726+
727+
Uses projected midnight SOC from today's trajectory as the starting
728+
point. Charge slots come from the unified selection (already stored
729+
in today_result). Sell slots are computed fresh using the same
730+
profitability filter and SOC validation as today.
731+
732+
Returns (tomorrow_scheduled_slots, tomorrow_soc_trajectory).
733+
"""
734+
tomorrow_prices = state.slot_prices_tomorrow
735+
if not tomorrow_prices or config.grid_mode == "off":
736+
return {}, []
737+
738+
num_slots = len(tomorrow_prices)
739+
minutes_per_slot = (24 * 60) / num_slots
740+
slot_duration_hours = minutes_per_slot / 60.0
741+
energy_per_slot = config.safe_power_kw * slot_duration_hours
742+
effective_per_slot = energy_per_slot * config.efficiency
743+
round_trip_eff = config.efficiency * config.efficiency
744+
745+
# Projected midnight SOC from today's trajectory (last value)
746+
min_kwh = (config.battery_discharge_min_pct / 100.0) * config.battery_capacity_kwh
747+
if today_soc_trajectory:
748+
midnight_pct = today_soc_trajectory[-1]
749+
midnight_kwh = max(min_kwh, (midnight_pct / 100.0) * config.battery_capacity_kwh)
750+
else:
751+
midnight_kwh = min_kwh
752+
753+
# Build remaining slots for tomorrow (all are future)
754+
remaining = [(i, tomorrow_prices[i]) for i in range(num_slots)
755+
if tomorrow_prices[i] is not None]
756+
if not remaining:
757+
return {}, []
758+
759+
# Charge slots: from unified selection stored on today_result
760+
scheduled: dict[int, str] = {}
761+
charge_indices: set[int] = set()
762+
763+
# The unified charge selection already picked tomorrow's charge slots.
764+
# Reconstruct them: they're the cheapest slots up to tomorrow_planned_slots.
765+
if today_result.tomorrow_planned_slots > 0 and config.grid_mode in ("from_grid", "both"):
766+
neg = [(i, p) for i, p in remaining if p < 0]
767+
non_neg = sorted([(i, p) for i, p in remaining if p >= 0], key=lambda x: x[1])
768+
neg_energy = len(neg) * effective_per_slot
769+
deficit = today_result.tomorrow_planned_kwh
770+
non_neg_needed = math.ceil(max(0, deficit - neg_energy) / effective_per_slot) if effective_per_slot > 0 else 0
771+
charge_slots = neg + non_neg[:non_neg_needed]
772+
for idx, _ in charge_slots:
773+
scheduled[idx] = "charge"
774+
charge_indices.add(idx)
775+
776+
# Synthesize hourly PV for tomorrow (distribute total across daylight 6-18)
777+
pv_tomorrow_total = state.pv_forecast_tomorrow or 0.0
778+
daylight_hours = list(range(6, 18))
779+
pv_per_daylight_hour = pv_tomorrow_total / len(daylight_hours) if daylight_hours else 0.0
780+
pv_hourly_tomorrow: dict[int, float] = {}
781+
for h in daylight_hours:
782+
pv_hourly_tomorrow[h] = pv_per_daylight_hour
783+
784+
# Sell slots (to_grid or both mode)
785+
if config.grid_mode in ("to_grid", "both"):
786+
reserve_kwh = calculate_self_consumption_reserve(
787+
config.consumption_est_kwh, state.pv_hourly_kwh)
788+
reserve_target = _compute_reserve_target(config, reserve_kwh)
789+
790+
charge_energy = len(charge_indices) * effective_per_slot
791+
max_battery_kwh = (config.battery_charge_max_pct / 100.0) * config.battery_capacity_kwh
792+
793+
# Arbitrage check for tomorrow
794+
arbitrage_active = False
795+
if config.arbitrage_price_delta > 0 and remaining:
796+
prices_vals = [p for _, p in remaining]
797+
spread = max(prices_vals) - min(prices_vals)
798+
if spread >= config.arbitrage_price_delta:
799+
arbitrage_active = True
800+
801+
if arbitrage_active:
802+
sellable = max(0.0, max_battery_kwh - reserve_target) * config.efficiency * 0.85
803+
else:
804+
peak_kwh = min(max_battery_kwh, midnight_kwh + pv_tomorrow_total + charge_energy)
805+
sellable = max(0.0, peak_kwh - reserve_target) * config.efficiency * 0.85
806+
807+
if sellable > 0:
808+
available = [(i, p) for i, p in remaining
809+
if p > 0 and i not in charge_indices]
810+
if config.grid_mode == "both" and charge_indices:
811+
max_buy = max(tomorrow_prices[i] for i in charge_indices
812+
if tomorrow_prices[i] is not None)
813+
min_sell = max_buy / round_trip_eff
814+
available = [(i, p) for i, p in available if p >= min_sell]
815+
816+
available.sort(key=lambda x: -x[1])
817+
sell_needed = math.ceil(sellable / energy_per_slot) if energy_per_slot > 0 else 0
818+
sell_selected = available[:sell_needed]
819+
820+
# SOC validation — use synthesized PV hourly for tomorrow
821+
consumption_per_slot = config.consumption_est_kwh / num_slots
822+
discharge_set = {s[0] for s in sell_selected}
823+
_, validated_discharge = _validate_schedule_soc(
824+
remaining, charge_indices, discharge_set,
825+
midnight_kwh, consumption_per_slot,
826+
pv_hourly_tomorrow, minutes_per_slot, 1.0,
827+
config.battery_capacity_kwh, reserve_target,
828+
energy_per_slot, config.efficiency,
829+
consumption_hourly_kwh=state.consumption_hourly_kwh,
830+
inverter_max_power_kw=config.inverter_max_power_kw,
831+
safe_power_kw=config.safe_power_kw,
832+
)
833+
for idx, _ in sell_selected:
834+
if idx in validated_discharge:
835+
scheduled[idx] = "discharge"
836+
837+
# SOC trajectory for tomorrow
838+
trajectory: list[float] = []
839+
cap = config.battery_capacity_kwh
840+
soc = midnight_kwh
841+
842+
for i in range(num_slots):
843+
pct = max(0.0, min(100.0, (soc / cap) * 100.0)) if cap > 0 else 0.0
844+
trajectory.append(round(pct, 1))
845+
846+
hour = int((i * minutes_per_slot) / 60)
847+
pv_kwh_rate = pv_per_daylight_hour if hour in daylight_hours else 0.0
848+
pv_per_slot = pv_kwh_rate * slot_duration_hours
849+
850+
if state.consumption_hourly_kwh and hour in state.consumption_hourly_kwh:
851+
cons = state.consumption_hourly_kwh[hour] * slot_duration_hours
852+
else:
853+
cons = config.consumption_est_kwh / num_slots
854+
855+
delta = pv_per_slot - cons
856+
action = scheduled.get(i)
857+
if action == "charge":
858+
grid_kw = min(config.safe_power_kw,
859+
max(0.0, config.inverter_max_power_kw - pv_kwh_rate))
860+
delta += grid_kw * slot_duration_hours * config.efficiency
861+
elif action == "discharge" and soc > min_kwh:
862+
delta -= min(energy_per_slot, soc - min_kwh)
863+
864+
soc = max(min_kwh, min(cap, soc + delta))
865+
866+
return scheduled, trajectory
867+
868+
717869
def calculate_schedule(config: EMSConfig, state: EMSState) -> ScheduleResult:
718870
"""Calculate optimal charge/discharge schedule.
719871
@@ -804,6 +956,14 @@ def calculate_schedule(config: EMSConfig, state: EMSState) -> ScheduleResult:
804956
config, state,
805957
)
806958

959+
# Compute tomorrow's schedule and trajectory (if tomorrow prices exist)
960+
if state.slot_prices_tomorrow:
961+
tmr_slots, tmr_traj = _compute_tomorrow_schedule(
962+
config, state, result, result.soc_trajectory,
963+
)
964+
result.tomorrow_scheduled_slots = tmr_slots
965+
result.tomorrow_soc_trajectory = tmr_traj
966+
807967
return result
808968

809969

custom_components/ha_felicity/frontend/ha_felicity_ems.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -584,15 +584,38 @@ class FelicityEMSCard extends LitElement {
584584
showTomorrow = false;
585585
}
586586

587-
// For tomorrow, run a forecast simulation too (no current-slot offset)
588587
// Keep today's sim for unified stats (tomorrowChargeCount, tomorrowPlanned)
589588
this._todaySimResult = useBackendSchedule ? { ...simResult, slots: todaySlotData } : simResult;
590589
let displayData, displayThreshold;
591590
if (showTomorrow) {
592-
const tmrSim = this._simulateScheduleTomorrow(tomorrowSlotData, this._simOverrides);
593-
displayData = tmrSim?.slots ?? tomorrowSlotData;
594-
displayThreshold = tmrSim?.threshold;
595-
this._simResult = tmrSim; // stats reflect tomorrow preview
591+
const useBackendTomorrow = !hasAnyOverrides && tomorrowSlotData?.some(s => s.action != null);
592+
if (useBackendTomorrow) {
593+
// Backend is authoritative — use its schedule directly
594+
const sim = this._getAttr("schedule_status", "sim_params") || {};
595+
const safeMaxPower = this._getNumericState("safe_max_power") || 5000;
596+
const powerKw = Math.max(1, safeMaxPower / 1000);
597+
const granMin = this._getAttr("schedule_status", "slot_granularity_min") || Math.round((24 * 60) / tomorrowSlotData.length);
598+
const slotDur = granMin / 60;
599+
const eff = sim.efficiency || 0.90;
600+
const effectivePerSlot = powerKw * slotDur * eff;
601+
const chargeCount = tomorrowSlotData.filter(s => s.action === "charge").length;
602+
const dischargeCount = tomorrowSlotData.filter(s => s.action === "discharge").length;
603+
displayData = tomorrowSlotData;
604+
displayThreshold = null;
605+
this._simResult = {
606+
slots: tomorrowSlotData,
607+
chargeCount,
608+
dischargeCount,
609+
planned: Math.round((chargeCount * effectivePerSlot + dischargeCount * powerKw * slotDur) * 100) / 100,
610+
threshold: null,
611+
};
612+
} else {
613+
// Overrides active — run client-side simulation for preview
614+
const tmrSim = this._simulateScheduleTomorrow(tomorrowSlotData, this._simOverrides);
615+
displayData = tmrSim?.slots ?? tomorrowSlotData;
616+
displayThreshold = tmrSim?.threshold;
617+
this._simResult = tmrSim;
618+
}
596619
} else {
597620
displayData = useBackendSchedule ? todaySlotData : (simResult?.slots ?? todaySlotData);
598621
displayThreshold = simResult?.threshold;
@@ -776,10 +799,14 @@ class FelicityEMSCard extends LitElement {
776799
// Prefer backend-computed trajectory when no overrides are active.
777800
// Fall back to client-side simulation when user is previewing via
778801
// sliders (_simOverrides) or manual slot clicks (_slotOverrides).
779-
const backendTrajectory = (!hasAnyOverrides && !showTomorrow)
780-
? ((this._getAttr("schedule_status", "sim_params") || {}).backend_soc_trajectory || null)
781-
: null;
782-
const socTrajectory = (backendTrajectory && backendTrajectory.length === numSlots)
802+
let backendTrajectory = null;
803+
if (!hasAnyOverrides) {
804+
const simParams = this._getAttr("schedule_status", "sim_params") || {};
805+
backendTrajectory = showTomorrow
806+
? (simParams.backend_soc_trajectory_tomorrow || null)
807+
: (simParams.backend_soc_trajectory || null);
808+
}
809+
const socTrajectory = (backendTrajectory && backendTrajectory.length >= numSlots)
783810
? backendTrajectory
784811
: this._computeSocTrajectory(displayData, showTomorrow);
785812
const socHistory = this._getAttr("schedule_status", "soc_history") || {};

custom_components/ha_felicity/sensor.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,16 @@ def _build_attributes(self):
139139
"action": action,
140140
})
141141

142-
# Build tomorrow's slot data (prices only, no schedule yet)
142+
# Build tomorrow's slot data with backend-computed actions
143143
tomorrow_slot_data = []
144144
tomorrow_prices = self.coordinator.slot_prices_tomorrow
145+
tomorrow_scheduled = self.coordinator._tomorrow_scheduled_slots or {}
145146
if tomorrow_prices:
146147
for i, price in enumerate(tomorrow_prices):
147148
tomorrow_slot_data.append({
148149
"slot": i,
149150
"price": round(price, 4) if price is not None else None,
150-
"action": None,
151+
"action": tomorrow_scheduled.get(i),
151152
})
152153

153154
return {
@@ -186,6 +187,7 @@ def _build_attributes(self):
186187
"pv_confidence": getattr(self.coordinator, '_last_pv_confidence', 1.0),
187188
"consumption_hourly_profile": self.coordinator._hourly_consumption_profile or {},
188189
"backend_soc_trajectory": self.coordinator._backend_soc_trajectory,
190+
"backend_soc_trajectory_tomorrow": self.coordinator._backend_soc_trajectory_tomorrow,
189191
"inverter_max_power_kw": self.coordinator._inverter_max_power_kw,
190192
},
191193
"soc_history": self.coordinator._soc_history,

0 commit comments

Comments
 (0)