Skip to content

Commit 00686cf

Browse files
committed
feat: add external_solar_mode for AC-coupled PV setups
On AC-coupled installations the PV panels are wired to a separate inverter (e.g. SolarEdge, microinverters) and the battery inverter has no DC solar input. The only physical charging path is via the grid — surplus solar returns through the meter. With SOLAR_STORAGE hard-coded to grid_charge=False the battery sits idle the entire solar window. Adds an opt-in battery.external_solar_mode flag (default false, so DC-coupled users see no change). When enabled, the SOLAR_STORAGE intent maps to grid_charge=True in the inverter controller; all other intents keep their default mapping. Wired end-to-end: - BatterySettings dataclass + from_ha_config - InverterController helper applied in _map_intent_to_rates, get_period_settings, and get_detailed_period_groups - settings_store bootstrap defaults + schema migration - Settings → Battery tab toggle (PV coupling section) - Setup wizard load + complete payload Tests: 10 new behavioral tests covering the override in isolation and through get_period_settings / get_detailed_period_groups. Full unit suite (600) and backend suite (201) pass. Closes johanzander#162
1 parent 408eb61 commit 00686cf

10 files changed

Lines changed: 128 additions & 3 deletions

File tree

backend/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2958,6 +2958,7 @@ async def setup_complete(payload: APISetupCompletePayload):
29582958
"maxSoc": "max_soc",
29592959
"cycleCost": "cycle_cost_per_kwh",
29602960
"minActionProfitThreshold": "min_action_profit_threshold",
2961+
"externalSolarMode": "external_solar_mode",
29612962
}
29622963
if any(getattr(payload, f) is not None for f in _BATTERY_MAP) or (
29632964
payload.maxChargeDischargePower is not None
@@ -3086,6 +3087,7 @@ def _nn(d: dict) -> dict:
30863087
"maxDischargePowerKw": payload.maxChargeDischargePower,
30873088
"cycleCostPerKwh": payload.cycleCost,
30883089
"minActionProfitThreshold": payload.minActionProfitThreshold,
3090+
"externalSolarMode": payload.externalSolarMode,
30893091
}
30903092
)
30913093
if "home" in sections:

backend/api_dataclasses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ class APISetupCompletePayload(BaseModel):
978978
maxChargeDischargePower: float | None = None
979979
cycleCost: float | None = None
980980
minActionProfitThreshold: float | None = None
981+
externalSolarMode: bool | None = None
981982
# Home settings
982983
currency: str | None = None
983984
consumption: float | None = None

backend/settings_store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ def _bootstrap_defaults() -> dict:
378378
"max_discharge_power_kw": BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW,
379379
"cycle_cost_per_kwh": BATTERY_CHARGE_CYCLE_COST,
380380
"min_action_profit_threshold": BATTERY_MIN_ACTION_PROFIT_THRESHOLD,
381+
"external_solar_mode": False,
381382
},
382383
"home": {
383384
"default_hourly": HOME_HOURLY_CONSUMPTION_KWH,
@@ -465,6 +466,7 @@ def _migrate_schema(self) -> None:
465466
("charging_power_rate", BATTERY_DEFAULT_CHARGING_POWER_RATE),
466467
("efficiency_charge", BATTERY_EFFICIENCY_CHARGE),
467468
("efficiency_discharge", BATTERY_EFFICIENCY_DISCHARGE),
469+
("external_solar_mode", False),
468470
):
469471
if key not in battery:
470472
battery[key] = default

core/bess/inverter_controller.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ def compute_rates_for_period(
113113
intent = self.strategic_intents[period]
114114
return self._map_intent_to_rates(intent, battery_action_kw)
115115

116+
def _effective_grid_charge(self, intent: str, grid_charge: bool) -> bool:
117+
"""Apply the external_solar_mode override for SOLAR_STORAGE.
118+
119+
On AC-coupled PV setups the battery inverter has no DC solar input,
120+
so the only physical charging path is the grid (surplus solar
121+
returns through the meter). When external_solar_mode is enabled,
122+
SOLAR_STORAGE periods must use grid_charge=True or the battery
123+
sits idle the entire solar window.
124+
"""
125+
if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
126+
return True
127+
return grid_charge
128+
116129
def _map_intent_to_rates(
117130
self, intent: str, battery_action_kw: float
118131
) -> tuple[bool, int]:
@@ -128,7 +141,7 @@ def _map_intent_to_rates(
128141
if intent == "GRID_CHARGING":
129142
return True, 0
130143
elif intent == "SOLAR_STORAGE":
131-
return False, 0
144+
return self._effective_grid_charge(intent, False), 0
132145
elif intent == "LOAD_SUPPORT":
133146
if battery_action_kw < -0.01:
134147
discharge_rate = min(
@@ -215,7 +228,7 @@ def get_period_settings(self, period: int) -> dict:
215228
charge_rate = self.INTENT_TO_CONTROL[intent]["charge_rate"]
216229
else:
217230
control = self.INTENT_TO_CONTROL[intent]
218-
grid_charge = control["grid_charge"]
231+
grid_charge = self._effective_grid_charge(intent, control["grid_charge"])
219232
charge_rate = control["charge_rate"]
220233
discharge_rate = control["discharge_rate"]
221234

@@ -298,7 +311,9 @@ def get_detailed_period_groups(
298311
"period": period,
299312
"intent": intent,
300313
"mode": mode,
301-
"grid_charge": control["grid_charge"],
314+
"grid_charge": self._effective_grid_charge(
315+
intent, control["grid_charge"]
316+
),
302317
"charge_rate": control["charge_rate"],
303318
"discharge_rate": control["discharge_rate"],
304319
}

core/bess/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ class BatterySettings:
125125
)
126126
efficiency_charge: float = BATTERY_EFFICIENCY_CHARGE
127127
efficiency_discharge: float = BATTERY_EFFICIENCY_DISCHARGE
128+
# AC-coupled PV opt-in: when True, SOLAR_STORAGE periods enable grid
129+
# charging so the battery can AC-charge from surplus solar that flows
130+
# back through the meter (no DC solar input on the battery inverter).
131+
external_solar_mode: bool = False
128132
reserved_capacity: float = field(init=False)
129133
min_soe_kwh: float = field(init=False)
130134
max_soe_kwh: float = field(init=False)
@@ -169,6 +173,9 @@ def from_ha_config(self, config: dict) -> "BatterySettings":
169173
self.min_action_profit_threshold = battery_config.get(
170174
"min_action_profit_threshold", BATTERY_MIN_ACTION_PROFIT_THRESHOLD
171175
)
176+
self.external_solar_mode = battery_config.get(
177+
"external_solar_mode", False
178+
)
172179
self.__post_init__()
173180
return self
174181

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
("EXPORT_ARBITRAGE", 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"

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
@@ -39,6 +39,7 @@ const EMPTY_BATTERY: BatteryForm = {
3939
cycleCostPerKwh: 0,
4040
efficiencyCharge: 97, efficiencyDischarge: 97,
4141
temperatureDeratingEnabled: false, minActionProfit: 0,
42+
externalSolarMode: false,
4243
};
4344
const EMPTY_HOME: HomeForm = {
4445
consumption: 3.5, consumptionStrategy: 'sensor',
@@ -164,6 +165,7 @@ const SettingsPage: React.FC = () => {
164165
efficiencyDischarge: bat_s.efficiencyDischarge ?? 0.95,
165166
temperatureDeratingEnabled: bat_s.temperatureDerating?.enabled ?? false,
166167
minActionProfit: bat_s.minActionProfitThreshold ?? 0,
168+
externalSolarMode: bat_s.externalSolarMode ?? false,
167169
};
168170
setBatteryForm(bat);
169171
savedBattery.current = JSON.stringify(bat);
@@ -439,6 +441,7 @@ const SettingsPage: React.FC = () => {
439441
enabled: batteryForm.temperatureDeratingEnabled,
440442
weatherEntity: sensors.shared?.['weather_entity'] ?? '',
441443
},
444+
externalSolarMode: batteryForm.externalSolarMode,
442445
},
443446
growatt: {
444447
deviceId: inverterForm.deviceId,

frontend/src/pages/SetupWizardPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const SetupWizardPage: React.FC = () => {
4545
efficiencyDischarge: 97,
4646
temperatureDeratingEnabled: false,
4747
minActionProfit: 8.0,
48+
externalSolarMode: false,
4849
});
4950

5051
const [inverterForm, setInverterForm] = useState<InverterForm>({
@@ -205,6 +206,7 @@ const SetupWizardPage: React.FC = () => {
205206
efficiencyCharge: bat.efficiencyCharge ?? f.efficiencyCharge,
206207
efficiencyDischarge: bat.efficiencyDischarge ?? f.efficiencyDischarge,
207208
temperatureDeratingEnabled: bat.temperatureDeratingEnabled ?? f.temperatureDeratingEnabled,
209+
externalSolarMode: bat.externalSolarMode ?? f.externalSolarMode,
208210
}));
209211
setHomeForm(f => ({
210212
...f,
@@ -274,6 +276,7 @@ const SetupWizardPage: React.FC = () => {
274276
maxChargeDischargePower: batteryForm.maxChargeDischargePowerKw,
275277
cycleCost: batteryForm.cycleCostPerKwh,
276278
minActionProfitThreshold: batteryForm.minActionProfit,
279+
externalSolarMode: batteryForm.externalSolarMode,
277280
// Home
278281
currency: pricingForm.currency,
279282
consumption: homeForm.consumption,

frontend/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export interface BatterySettings {
114114
efficiencyCharge: number; // % charging efficiency
115115
efficiencyDischarge: number; // % discharge efficiency
116116

117+
// AC-coupled PV: route SOLAR_STORAGE through grid charging
118+
externalSolarMode?: boolean;
119+
117120
// Consumption estimate
118121
estimatedConsumption: number; // kWh daily estimate
119122
consumptionStrategy: string; // "sensor", "fixed", or "influxdb_7d_avg"

0 commit comments

Comments
 (0)