Skip to content

Commit c5707fa

Browse files
authored
Merge pull request #1 from jdungen/claude/ac-coupled-pv-solar-mode-o767wv
feat: add external_solar_mode for AC-coupled PV setups
2 parents b2fac04 + cba1b33 commit c5707fa

12 files changed

Lines changed: 215 additions & 9 deletions

backend/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3392,6 +3392,7 @@ async def setup_complete(payload: APISetupCompletePayload):
33923392
"maxSoc": "max_soc",
33933393
"cycleCost": "cycle_cost_per_kwh",
33943394
"minActionProfitThreshold": "min_action_profit_threshold",
3395+
"externalSolarMode": "external_solar_mode",
33953396
}
33963397
if any(getattr(payload, f) is not None for f in _BATTERY_MAP) or (
33973398
payload.maxChargeDischargePower is not None
@@ -3541,6 +3542,7 @@ def _nn(d: dict) -> dict:
35413542
"max_discharge_power_kw": payload.maxChargeDischargePower,
35423543
"cycle_cost_per_kwh": payload.cycleCost,
35433544
"min_action_profit_threshold": payload.minActionProfitThreshold,
3545+
"external_solar_mode": payload.externalSolarMode,
35443546
}
35453547
)
35463548
if "home" in sections:

backend/api_dataclasses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,7 @@ class APISetupCompletePayload(BaseModel):
10981098
maxChargeDischargePower: float | None = None
10991099
cycleCost: float | None = None
11001100
minActionProfitThreshold: float | None = None
1101+
externalSolarMode: bool | None = None
11011102
# Home settings
11021103
currency: str | None = None
11031104
consumption: float | None = None

backend/settings_store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def _bootstrap_defaults() -> dict:
387387
"max_discharge_power_kw": BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW,
388388
"cycle_cost_per_kwh": BATTERY_CHARGE_CYCLE_COST,
389389
"min_action_profit_threshold": BATTERY_MIN_ACTION_PROFIT_THRESHOLD,
390+
"external_solar_mode": False,
390391
},
391392
"home": {
392393
"default_hourly": HOME_HOURLY_CONSUMPTION_KWH,
@@ -481,6 +482,7 @@ def _migrate_schema(self) -> None:
481482
("charging_power_rate", BATTERY_DEFAULT_CHARGING_POWER_RATE),
482483
("efficiency_charge", BATTERY_EFFICIENCY_CHARGE),
483484
("efficiency_discharge", BATTERY_EFFICIENCY_DISCHARGE),
485+
("external_solar_mode", False),
484486
):
485487
if key not in battery:
486488
battery[key] = default

backend/tests/test_settings_contracts.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,12 @@ def test_missing_section_raises(self):
340340
# Fields present in the dataclass (and BATTERY_MODEL_ATTRS) but not required
341341
# at startup because they have class defaults.
342342
_BATTERY_OPTIONAL_FIELDS = frozenset(
343-
{"charging_power_rate", "efficiency_charge", "efficiency_discharge"}
343+
{
344+
"charging_power_rate",
345+
"efficiency_charge",
346+
"efficiency_discharge",
347+
"external_solar_mode",
348+
}
344349
)
345350
# min_valid is an internal algorithm parameter, never read from the settings
346351
# store or written by the wizard — the one field HOME_MODEL_ATTRS has that

core/bess/inverter_controller.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,41 @@ def _compute_charge_rate(
139139
return self._scale_to_percent(battery_action_kw, self.max_charge_power_kw)
140140
return control["charge_rate"]
141141

142+
def _effective_grid_charge(self, intent: str, grid_charge: bool) -> bool:
143+
"""Apply the external_solar_mode override for SOLAR_STORAGE.
144+
145+
On AC-coupled PV setups the battery inverter has no DC solar input,
146+
so the only physical charging path is the grid (surplus solar
147+
returns through the meter). When external_solar_mode is enabled,
148+
SOLAR_STORAGE periods must use grid_charge=True or the battery
149+
sits idle the entire solar window.
150+
"""
151+
if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
152+
return True
153+
return grid_charge
154+
155+
def _effective_mode_for_intent(self, intent: str, default_mode: str) -> str:
156+
"""Apply the external_solar_mode override for the battery mode.
157+
158+
For DC-coupled setups, Load First mode is correct for SOLAR_STORAGE
159+
because the inverter naturally routes surplus solar (seen on its own
160+
MPPT) to the battery. On AC-coupled setups the battery inverter has
161+
no DC solar input, so Load First mode produces no charging action
162+
even with grid_charge enabled — the EMS waits for a trigger that
163+
never comes. Switching SOLAR_STORAGE to Battery First makes the
164+
inverter actively charge from the AC side during the planned solar
165+
window.
166+
167+
Trade-off: Battery First charges at the configured rate regardless
168+
of actual solar surplus, so during a SOLAR_STORAGE period with
169+
insufficient solar export the battery will draw from the grid.
170+
BESS only plans SOLAR_STORAGE when the forecast shows surplus, so
171+
the risk is bounded by forecast accuracy.
172+
"""
173+
if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
174+
return "battery_first"
175+
return default_mode
176+
142177
def _map_intent_to_rates(
143178
self, intent: str, battery_action_kw: float
144179
) -> tuple[bool, int]:
@@ -154,7 +189,7 @@ def _map_intent_to_rates(
154189
if intent == "GRID_CHARGING":
155190
return True, 0
156191
elif intent == "SOLAR_STORAGE":
157-
return False, 0
192+
return self._effective_grid_charge(intent, False), 0
158193
elif intent in ("LOAD_SUPPORT", "BATTERY_EXPORT"):
159194
if battery_action_kw < -0.01:
160195
discharge_rate = self._scale_to_percent(
@@ -206,7 +241,7 @@ def get_period_settings(self, period: int) -> dict:
206241
)
207242

208243
intent = self.strategic_intents[period]
209-
mode = self.INTENT_TO_MODE[intent]
244+
mode = self._effective_mode_for_intent(intent, self.INTENT_TO_MODE[intent])
210245

211246
if (
212247
self.current_schedule is not None
@@ -225,7 +260,7 @@ def get_period_settings(self, period: int) -> dict:
225260
)
226261
else:
227262
control = self.INTENT_TO_CONTROL[intent]
228-
grid_charge = control["grid_charge"]
263+
grid_charge = self._effective_grid_charge(intent, control["grid_charge"])
229264
charge_rate = control["charge_rate"]
230265
discharge_rate = control["discharge_rate"]
231266

@@ -312,7 +347,9 @@ def get_detailed_period_groups(
312347
period_settings = []
313348
for period in range(num_periods):
314349
intent = effective_intents[period]
315-
mode = self.INTENT_TO_MODE.get(intent, "load_first")
350+
mode = self._effective_mode_for_intent(
351+
intent, self.INTENT_TO_MODE.get(intent, "load_first")
352+
)
316353
control = self.INTENT_TO_CONTROL.get(
317354
intent,
318355
{"grid_charge": False, "charge_rate": 100, "discharge_rate": 0},
@@ -331,7 +368,9 @@ def get_detailed_period_groups(
331368
"period": period,
332369
"intent": intent,
333370
"mode": mode,
334-
"grid_charge": control["grid_charge"],
371+
"grid_charge": self._effective_grid_charge(
372+
intent, control["grid_charge"]
373+
),
335374
"charge_rate": charge_rate,
336375
"discharge_rate": discharge_rate,
337376
"action_kwh": action_kwh,

core/bess/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ class BatterySettings:
129129
)
130130
efficiency_charge: float = BATTERY_EFFICIENCY_CHARGE
131131
efficiency_discharge: float = BATTERY_EFFICIENCY_DISCHARGE
132+
# AC-coupled PV opt-in: when True, SOLAR_STORAGE periods enable grid
133+
# charging so the battery can AC-charge from surplus solar that flows
134+
# back through the meter (no DC solar input on the battery inverter).
135+
external_solar_mode: bool = False
132136
reserved_capacity: float = field(init=False)
133137
min_soe_kwh: float = field(init=False)
134138
max_soe_kwh: float = field(init=False)
@@ -176,6 +180,7 @@ def from_ha_config(self, config: dict) -> "BatterySettings":
176180
self.min_action_profit_threshold = battery_config.get(
177181
"min_action_profit_threshold", BATTERY_MIN_ACTION_PROFIT_THRESHOLD
178182
)
183+
self.external_solar_mode = battery_config.get("external_solar_mode", False)
179184
self.__post_init__()
180185
return self
181186

core/bess/solax_modbus_growatt_controller.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,9 @@ def _apply_period_tou(
183183
mode = "load_first"
184184
if current_period < len(self.strategic_intents):
185185
intent = self.strategic_intents[current_period]
186-
mode = self.INTENT_TO_MODE.get(intent, "load_first")
186+
mode = self._effective_mode_for_intent(
187+
intent, self.INTENT_TO_MODE.get(intent, "load_first")
188+
)
187189

188190
if mode != self._last_written_tou_mode:
189191
enabled = mode != "load_first"
@@ -326,7 +328,9 @@ def write_schedule_to_hardware(
326328
mode = "load_first"
327329
if effective_period < len(self.strategic_intents):
328330
intent = self.strategic_intents[effective_period]
329-
mode = self.INTENT_TO_MODE.get(intent, "load_first")
331+
mode = self._effective_mode_for_intent(
332+
intent, self.INTENT_TO_MODE.get(intent, "load_first")
333+
)
330334

331335
enabled = mode != "load_first"
332336
logger.info(
@@ -533,7 +537,9 @@ def get_all_tou_segments(self, current_period: int | None = None):
533537

534538
result = []
535539
for group in groups:
536-
mode = self.INTENT_TO_MODE.get(group["intent"], "load_first")
540+
mode = self._effective_mode_for_intent(
541+
group["intent"], self.INTENT_TO_MODE.get(group["intent"], "load_first")
542+
)
537543
is_current = group["start_period"] <= current_p <= group["end_period"]
538544
result.append(
539545
{
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Tests for the AC-coupled `external_solar_mode` battery setting.
2+
3+
When enabled, the SOLAR_STORAGE strategic intent must translate to
4+
`grid_charge=True` so the battery can AC-charge from surplus solar that
5+
returns via the meter (the battery inverter has no DC solar input).
6+
All other intents must keep their default mapping.
7+
"""
8+
9+
import pytest
10+
11+
from core.bess.settings import BatterySettings
12+
from core.bess.solax_controller import SolaxController
13+
14+
15+
def _settings(*, external_solar_mode: bool) -> BatterySettings:
16+
return BatterySettings(
17+
total_capacity=10.0,
18+
max_charge_power_kw=5.0,
19+
max_discharge_power_kw=5.0,
20+
min_soc=15.0,
21+
max_soc=95.0,
22+
external_solar_mode=external_solar_mode,
23+
)
24+
25+
26+
class TestExternalSolarModeOverride:
27+
def test_default_is_disabled(self) -> None:
28+
assert BatterySettings(total_capacity=10.0).external_solar_mode is False
29+
30+
def test_solar_storage_grid_charge_false_when_disabled(self) -> None:
31+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
32+
grid_charge, discharge_rate = ctrl._map_intent_to_rates("SOLAR_STORAGE", 0.0)
33+
assert grid_charge is False
34+
assert discharge_rate == 0
35+
36+
def test_solar_storage_grid_charge_true_when_enabled(self) -> None:
37+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
38+
grid_charge, discharge_rate = ctrl._map_intent_to_rates("SOLAR_STORAGE", 0.0)
39+
assert grid_charge is True
40+
assert discharge_rate == 0
41+
42+
@pytest.mark.parametrize(
43+
"intent,expected_grid_charge",
44+
[
45+
("GRID_CHARGING", True),
46+
("LOAD_SUPPORT", False),
47+
("BATTERY_EXPORT", False),
48+
("IDLE", False),
49+
],
50+
)
51+
def test_other_intents_unaffected_when_enabled(
52+
self, intent: str, expected_grid_charge: bool
53+
) -> None:
54+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
55+
grid_charge, _ = ctrl._map_intent_to_rates(intent, 0.0)
56+
assert grid_charge is expected_grid_charge
57+
58+
def test_detailed_period_groups_apply_override(self) -> None:
59+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
60+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
61+
groups = ctrl.get_detailed_period_groups()
62+
assert groups, "expected at least one period group"
63+
for group in groups:
64+
assert group["grid_charge"] is True
65+
assert group["intent"] == "SOLAR_STORAGE"
66+
67+
def test_detailed_period_groups_no_override_when_disabled(self) -> None:
68+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
69+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
70+
groups = ctrl.get_detailed_period_groups()
71+
for group in groups:
72+
assert group["grid_charge"] is False
73+
74+
def test_get_period_settings_applies_override_without_schedule(self) -> None:
75+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
76+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
77+
ctrl.current_schedule = None
78+
settings = ctrl.get_period_settings(period=10)
79+
assert settings["grid_charge"] is True
80+
assert settings["strategic_intent"] == "SOLAR_STORAGE"
81+
82+
83+
class TestExternalSolarModeBattModeOverride:
84+
"""external_solar_mode should also flip SOLAR_STORAGE's mode to battery_first.
85+
86+
On AC-coupled setups, Load First mode does not initiate battery charging
87+
even with grid_charge enabled — the EMS waits for a trigger that never
88+
comes. Battery First mode makes the inverter actively charge from the
89+
AC side.
90+
"""
91+
92+
def test_solar_storage_mode_is_load_first_when_disabled(self) -> None:
93+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
94+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
95+
settings = ctrl.get_period_settings(period=10)
96+
assert settings["batt_mode"] == "load_first"
97+
98+
def test_solar_storage_mode_is_battery_first_when_enabled(self) -> None:
99+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
100+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
101+
settings = ctrl.get_period_settings(period=10)
102+
assert settings["batt_mode"] == "battery_first"
103+
104+
@pytest.mark.parametrize(
105+
"intent,expected_mode",
106+
[
107+
("GRID_CHARGING", "battery_first"),
108+
("LOAD_SUPPORT", "load_first"),
109+
("BATTERY_EXPORT", "grid_first"),
110+
("IDLE", "load_first"),
111+
],
112+
)
113+
def test_other_intents_unaffected_when_enabled(
114+
self, intent: str, expected_mode: str
115+
) -> None:
116+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
117+
ctrl.strategic_intents = [intent] * 96
118+
settings = ctrl.get_period_settings(period=10)
119+
assert settings["batt_mode"] == expected_mode
120+
121+
def test_detailed_period_groups_apply_mode_override(self) -> None:
122+
ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
123+
ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
124+
groups = ctrl.get_detailed_period_groups()
125+
assert groups, "expected at least one period group"
126+
for group in groups:
127+
assert group["mode"] == "battery_first"
128+
assert group["grid_charge"] is True

frontend/src/components/settings/BatteryFormSection.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface BatteryForm {
1212
efficiencyDischarge: number;
1313
temperatureDeratingEnabled: boolean;
1414
minActionProfit: number;
15+
externalSolarMode: boolean;
1516
}
1617

1718
interface Props {
@@ -54,6 +55,14 @@ export function BatteryFormSection({
5455
</div>
5556
</SectionCard>
5657

58+
<SectionCard
59+
title="PV coupling"
60+
description="Enable only if your solar panels are wired to a separate inverter (e.g. SolarEdge or microinverters) and the battery inverter has no DC solar input. Surplus solar reaches the battery via the grid, so SOLAR_STORAGE periods must enable AC charging."
61+
>
62+
{toggle('External solar mode (AC-coupled PV)', form.externalSolarMode,
63+
v => onChange({ ...form, externalSolarMode: v }))}
64+
</SectionCard>
65+
5766
{/* Advanced settings collapsible — hidden in wizard mode since these
5867
fields are not sent by the wizard completion payload */}
5968
{!hideAdvanced && <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">

frontend/src/pages/SettingsPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const EMPTY_BATTERY: BatteryForm = {
4040
cycleCostPerKwh: 0,
4141
efficiencyCharge: 97, efficiencyDischarge: 97,
4242
temperatureDeratingEnabled: false, minActionProfit: 0,
43+
externalSolarMode: false,
4344
};
4445
const EMPTY_HOME: HomeForm = {
4546
consumption: 3.5, consumptionStrategy: 'sensor',
@@ -170,6 +171,7 @@ const SettingsPage: React.FC = () => {
170171
efficiencyDischarge: bat_s.efficiencyDischarge ?? 0.95,
171172
temperatureDeratingEnabled: bat_s.temperatureDerating?.enabled ?? false,
172173
minActionProfit: bat_s.minActionProfitThreshold ?? 0,
174+
externalSolarMode: bat_s.externalSolarMode ?? false,
173175
};
174176
setBatteryForm(bat);
175177
savedBattery.current = JSON.stringify(bat);
@@ -452,6 +454,7 @@ const SettingsPage: React.FC = () => {
452454
enabled: batteryForm.temperatureDeratingEnabled,
453455
weatherEntity: sensors.shared?.['weather_entity'] ?? '',
454456
},
457+
externalSolarMode: batteryForm.externalSolarMode,
455458
},
456459
growatt: {
457460
deviceId: inverterForm.deviceId,

0 commit comments

Comments
 (0)