Skip to content

Commit 9a46608

Browse files
committed
Sanity check fixes: expose multi-objective knobs in UI, fix doc drift
Audit of implementation vs CLAUDE.md surfaced several issues: Bugs/gaps fixed: - The three lifecycle-overhaul knobs (battery_cycle_cost_eur_kwh, optimization_priority, block_export_on_negative_price) had no UI entities — users could not change them. Added: * battery_cycle_cost_eur_kwh -> number (0-0.50 EUR/kWh) * optimization_priority -> select (cost/longevity/self_consumption) * block_export_on_negative_price -> select (on/off) - coordinator now converts block_export string ("on"/"off") to bool correctly (str(...).lower() not in off/false/0), tolerating the legacy bool value too - reserve_target_pct and arbitrage_price_delta were missing default_value, so they greyed out on upgrades — added defaults - __init__.py defaults_to_set was missing 6 keys (reserve_target_pct, arbitrage_price_delta, max_amperage_per_phase, battery_cycle_cost, optimization_priority, block_export) so existing installs upgrading never backfilled them — added Documentation fixes (CLAUDE.md): - battery_capacity_kwh range 1-100 -> 1-200 (matches code) - daily_consumption_estimate range 0-100 -> 0-120 (matches code) - added the 3 new config entities to the Configuration Entities table - fixed stale coordinator line-number references for State Transitions and Safe Power Management - documented that discharging SOC floor uses computed reserve_target in auto mode (was undocumented) - added C6 section describing the new UI entities ems.py and the frontend cards audited clean — no inconsistencies. 148 tests pass, ruff clean. https://claude.ai/code/session_01P9LAQ5ET6SU9dLWULxv2uF
1 parent 6490894 commit 9a46608

6 files changed

Lines changed: 80 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,21 @@ the cheapest scheduled slot. This compensates for deficit shrinking as PV
9797
confidence recovers mid-day — without it, early expensive slots would
9898
execute while later cheaper slots got dropped from a re-plan.
9999

100-
### State Transitions (coordinator.py:1195-1231)
100+
### State Transitions (coordinator.py `_transition_to_state`, ~1425-1482)
101101

102102
| State | econ_rule_1_enable | Voltage | SOC | Power |
103103
|---|---|---|---|---|
104104
| charging | 1 | voltage_level (default 58V) | charge_max (default 100%) | safe_max_power (W) |
105-
| discharging | 2 | discharge_min_voltage (default 50V) | discharge_min (default 20%) | safe_max_power (W) |
105+
| discharging | 2 | discharge_min_voltage (default 50V) | discharge floor (see below) | safe_max_power (W) |
106106
| idle | 0 | *(not written)* | *(not written)* | *(not written)* |
107107

108+
**Discharge SOC floor**: in auto mode (grid_mode active) the discharging
109+
SOC register is written from the *computed* `_reserve_target_pct`
110+
(the planned reserve target), not the raw `battery_discharge_min_level`.
111+
This makes the inverter's hardware floor match the schedule's intended
112+
reserve rather than the absolute minimum. When the EMS is off it falls
113+
back to the user's `discharge_min` setting.
114+
108115
### Model Differences
109116

110117
| Aspect | TREX-5 | TREX-10 | TREX-25 | TREX-50 |
@@ -262,7 +269,7 @@ max_today_slots = floor(headroom / effective_per_slot)
262269

263270
---
264271

265-
## Safe Power Management (coordinator.py:1077-1193)
272+
## Safe Power Management (coordinator.py `_check_safe_power`, ~1307-1424)
266273

267274
Monitors grid current per phase and adjusts inverter power:
268275

@@ -323,11 +330,14 @@ Fetches `energy_state` history from HA API (throttled 60s), shows what actually
323330
| price_threshold_level | number | 1-10 | 5 | Manual price level |
324331
| battery_charge_max_level | number | 30-100% | 100 | Max SOC for charging |
325332
| battery_discharge_min_level | number | 10-70% | 20 | Min SOC for discharging |
326-
| battery_capacity_kwh | number | 1-100 kWh | 10 | Usable battery capacity |
333+
| battery_capacity_kwh | number | 1-200 kWh | 10 | Usable battery capacity |
327334
| efficiency_factor | number | 0.70-1.00 | 0.90 | Round-trip efficiency |
328-
| daily_consumption_estimate | number | 0-100 kWh | 10 | Fallback consumption |
335+
| daily_consumption_estimate | number | 0-120 kWh | 10 | Fallback consumption |
329336
| reserve_target_pct | number | 0-100% | 0 | Fixed reserve floor (0=dynamic) |
330337
| arbitrage_price_delta | number | 0-0.50 €/kWh | 0 | Price spread for full charge |
338+
| battery_cycle_cost_eur_kwh | number | 0-0.50 €/kWh | 0 | Battery wear cost (profitability filter) |
339+
| optimization_priority | select | cost/longevity/self_consumption | cost | Multi-objective strategy |
340+
| block_export_on_negative_price | select | on/off | on | Skip sell scheduling at p < 0 |
331341
| max_amperage_per_phase | number | 10-63 A | 16 | Grid current limit |
332342
| voltage_level | number | 48-60 V | 58 | Charge voltage setpoint |
333343
| discharge_min_voltage | number | 48-55 V | 50 | Discharge voltage floor |
@@ -564,7 +574,23 @@ other entities) to be unusable on fresh installs.
564574
keys (`max_amperage_per_phase`, `safe_power_management`,
565575
`battery_cycle_cost_eur_kwh`, `optimization_priority`,
566576
`block_export_on_negative_price`) so new installations get them
567-
written at setup time.
577+
written at setup time. The migration block `defaults_to_set` in
578+
`__init__.py` (runs on every `async_setup_entry`) mirrors the same
579+
keys so *existing* installs upgrading to a newer version backfill
580+
missing options too.
581+
582+
#### C6. UI Entities for Multi-Objective Knobs — IMPLEMENTED
583+
The three knobs added in the lifecycle overhaul previously had no UI
584+
control — they lived only in `entry.options` defaults and could not be
585+
changed by the user. They now have entities:
586+
- `battery_cycle_cost_eur_kwh` → number (0-0.50 €/kWh, number.py)
587+
- `optimization_priority` → select (cost/longevity/self_consumption, select.py)
588+
- `block_export_on_negative_price` → select (on/off, select.py)
589+
590+
Because `block_export_on_negative_price` is stored as a string ("on"/"off")
591+
by the select but consumed as a bool by `EMSConfig`, the coordinator
592+
converts it: `str(opts.get(..., "on")).lower() not in ("off","false","0")`.
593+
This also tolerates the legacy bool value from earlier installs.
568594

569595
### Deferred Items (Need Separate Iteration)
570596

custom_components/ha_felicity/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ 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+
"reserve_target_pct": 0,
195+
"arbitrage_price_delta": 0.0,
196+
"max_amperage_per_phase": 16,
197+
"battery_cycle_cost_eur_kwh": 0.0,
198+
"optimization_priority": "cost",
199+
"block_export_on_negative_price": "on",
194200
CONF_REGISTER_SET: DEFAULT_REGISTER_SET,
195201
"update_interval": 10,
196202
}

custom_components/ha_felicity/config_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def _get_default_options(self) -> dict:
9090
"safe_power_management": self._user_input.get("safe_power_management", "auto"),
9191
"battery_cycle_cost_eur_kwh": self._user_input.get("battery_cycle_cost_eur_kwh", 0.0),
9292
"optimization_priority": self._user_input.get("optimization_priority", "cost"),
93-
"block_export_on_negative_price": self._user_input.get("block_export_on_negative_price", True),
93+
"block_export_on_negative_price": self._user_input.get("block_export_on_negative_price", "on"),
9494
}
9595

9696
@staticmethod

custom_components/ha_felicity/coordinator.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -673,9 +673,9 @@ def _calculate_schedule(self, battery_soc: float | None) -> None:
673673
arbitrage_price_delta=opts.get("arbitrage_price_delta", 0.0),
674674
battery_cycle_cost_eur_kwh=opts.get("battery_cycle_cost_eur_kwh", 0.0),
675675
optimization_priority=opts.get("optimization_priority", "cost"),
676-
block_export_on_negative_price=opts.get(
677-
"block_export_on_negative_price", True
678-
),
676+
block_export_on_negative_price=str(
677+
opts.get("block_export_on_negative_price", "on")
678+
).lower() not in ("off", "false", "0"),
679679
)
680680

681681
state = ems_module.EMSState(

custom_components/ha_felicity/number.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ async def async_setup_entry(
175175
step=5,
176176
unit="%",
177177
icon="mdi:battery-lock",
178+
default_value=0,
178179
),
179180
HA_FelicityInternalNumber(
180181
coordinator,
@@ -186,6 +187,19 @@ async def async_setup_entry(
186187
step=0.01,
187188
unit="€/kWh",
188189
icon="mdi:cash-multiple",
190+
default_value=0.0,
191+
),
192+
HA_FelicityInternalNumber(
193+
coordinator,
194+
entry,
195+
option_key="battery_cycle_cost_eur_kwh",
196+
name="Battery Cycle Cost",
197+
min_val=0,
198+
max_val=0.50,
199+
step=0.01,
200+
unit="€/kWh",
201+
icon="mdi:battery-clock",
202+
default_value=0.0,
189203
),
190204
])
191205

custom_components/ha_felicity/select.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@ async def async_setup_entry(
8383
)
8484
)
8585

86+
entities.append(
87+
HA_FelicitySpecialModeSelect(
88+
coordinator=coordinator,
89+
entry=entry,
90+
option_key="optimization_priority",
91+
select_options=["cost", "longevity", "self_consumption"],
92+
name="Optimization Priority",
93+
icon="mdi:scale-balance",
94+
entity_category=EntityCategory.CONFIG,
95+
)
96+
)
97+
98+
entities.append(
99+
HA_FelicitySpecialModeSelect(
100+
coordinator=coordinator,
101+
entry=entry,
102+
option_key="block_export_on_negative_price",
103+
select_options=["on", "off"],
104+
name="Block Export On Negative Price",
105+
icon="mdi:transmission-tower-export",
106+
entity_category=EntityCategory.CONFIG,
107+
)
108+
)
109+
86110
# Tie all entities to the device
87111
for entity in entities:
88112
entity._attr_device_info = device_info

0 commit comments

Comments
 (0)