diff --git a/backend/api.py b/backend/api.py
index 85b3ba0f..f1fd7be2 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -3392,6 +3392,7 @@ async def setup_complete(payload: APISetupCompletePayload):
"maxSoc": "max_soc",
"cycleCost": "cycle_cost_per_kwh",
"minActionProfitThreshold": "min_action_profit_threshold",
+ "externalSolarMode": "external_solar_mode",
}
if any(getattr(payload, f) is not None for f in _BATTERY_MAP) or (
payload.maxChargeDischargePower is not None
@@ -3541,6 +3542,7 @@ def _nn(d: dict) -> dict:
"max_discharge_power_kw": payload.maxChargeDischargePower,
"cycle_cost_per_kwh": payload.cycleCost,
"min_action_profit_threshold": payload.minActionProfitThreshold,
+ "external_solar_mode": payload.externalSolarMode,
}
)
if "home" in sections:
diff --git a/backend/api_dataclasses.py b/backend/api_dataclasses.py
index d27879f4..f2e4a6b4 100644
--- a/backend/api_dataclasses.py
+++ b/backend/api_dataclasses.py
@@ -1098,6 +1098,7 @@ class APISetupCompletePayload(BaseModel):
maxChargeDischargePower: float | None = None
cycleCost: float | None = None
minActionProfitThreshold: float | None = None
+ externalSolarMode: bool | None = None
# Home settings
currency: str | None = None
consumption: float | None = None
diff --git a/backend/settings_store.py b/backend/settings_store.py
index 1fa2c124..528922be 100644
--- a/backend/settings_store.py
+++ b/backend/settings_store.py
@@ -387,6 +387,7 @@ def _bootstrap_defaults() -> dict:
"max_discharge_power_kw": BATTERY_MAX_CHARGE_DISCHARGE_POWER_KW,
"cycle_cost_per_kwh": BATTERY_CHARGE_CYCLE_COST,
"min_action_profit_threshold": BATTERY_MIN_ACTION_PROFIT_THRESHOLD,
+ "external_solar_mode": False,
},
"home": {
"default_hourly": HOME_HOURLY_CONSUMPTION_KWH,
@@ -481,6 +482,7 @@ def _migrate_schema(self) -> None:
("charging_power_rate", BATTERY_DEFAULT_CHARGING_POWER_RATE),
("efficiency_charge", BATTERY_EFFICIENCY_CHARGE),
("efficiency_discharge", BATTERY_EFFICIENCY_DISCHARGE),
+ ("external_solar_mode", False),
):
if key not in battery:
battery[key] = default
diff --git a/backend/tests/test_settings_contracts.py b/backend/tests/test_settings_contracts.py
index eee89f4b..4a72d0d2 100644
--- a/backend/tests/test_settings_contracts.py
+++ b/backend/tests/test_settings_contracts.py
@@ -340,7 +340,12 @@ def test_missing_section_raises(self):
# Fields present in the dataclass (and BATTERY_MODEL_ATTRS) but not required
# at startup because they have class defaults.
_BATTERY_OPTIONAL_FIELDS = frozenset(
- {"charging_power_rate", "efficiency_charge", "efficiency_discharge"}
+ {
+ "charging_power_rate",
+ "efficiency_charge",
+ "efficiency_discharge",
+ "external_solar_mode",
+ }
)
# min_valid is an internal algorithm parameter, never read from the settings
# store or written by the wizard — the one field HOME_MODEL_ATTRS has that
diff --git a/core/bess/inverter_controller.py b/core/bess/inverter_controller.py
index 37e85775..55601daf 100644
--- a/core/bess/inverter_controller.py
+++ b/core/bess/inverter_controller.py
@@ -139,6 +139,41 @@ def _compute_charge_rate(
return self._scale_to_percent(battery_action_kw, self.max_charge_power_kw)
return control["charge_rate"]
+ def _effective_grid_charge(self, intent: str, grid_charge: bool) -> bool:
+ """Apply the external_solar_mode override for SOLAR_STORAGE.
+
+ On AC-coupled PV setups the battery inverter has no DC solar input,
+ so the only physical charging path is the grid (surplus solar
+ returns through the meter). When external_solar_mode is enabled,
+ SOLAR_STORAGE periods must use grid_charge=True or the battery
+ sits idle the entire solar window.
+ """
+ if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
+ return True
+ return grid_charge
+
+ def _effective_mode_for_intent(self, intent: str, default_mode: str) -> str:
+ """Apply the external_solar_mode override for the battery mode.
+
+ For DC-coupled setups, Load First mode is correct for SOLAR_STORAGE
+ because the inverter naturally routes surplus solar (seen on its own
+ MPPT) to the battery. On AC-coupled setups the battery inverter has
+ no DC solar input, so Load First mode produces no charging action
+ even with grid_charge enabled — the EMS waits for a trigger that
+ never comes. Switching SOLAR_STORAGE to Battery First makes the
+ inverter actively charge from the AC side during the planned solar
+ window.
+
+ Trade-off: Battery First charges at the configured rate regardless
+ of actual solar surplus, so during a SOLAR_STORAGE period with
+ insufficient solar export the battery will draw from the grid.
+ BESS only plans SOLAR_STORAGE when the forecast shows surplus, so
+ the risk is bounded by forecast accuracy.
+ """
+ if intent == "SOLAR_STORAGE" and self.battery_settings.external_solar_mode:
+ return "battery_first"
+ return default_mode
+
def _map_intent_to_rates(
self, intent: str, battery_action_kw: float
) -> tuple[bool, int]:
@@ -154,7 +189,7 @@ def _map_intent_to_rates(
if intent == "GRID_CHARGING":
return True, 0
elif intent == "SOLAR_STORAGE":
- return False, 0
+ return self._effective_grid_charge(intent, False), 0
elif intent in ("LOAD_SUPPORT", "BATTERY_EXPORT"):
if battery_action_kw < -0.01:
discharge_rate = self._scale_to_percent(
@@ -206,7 +241,7 @@ def get_period_settings(self, period: int) -> dict:
)
intent = self.strategic_intents[period]
- mode = self.INTENT_TO_MODE[intent]
+ mode = self._effective_mode_for_intent(intent, self.INTENT_TO_MODE[intent])
if (
self.current_schedule is not None
@@ -225,7 +260,7 @@ def get_period_settings(self, period: int) -> dict:
)
else:
control = self.INTENT_TO_CONTROL[intent]
- grid_charge = control["grid_charge"]
+ grid_charge = self._effective_grid_charge(intent, control["grid_charge"])
charge_rate = control["charge_rate"]
discharge_rate = control["discharge_rate"]
@@ -312,7 +347,9 @@ def get_detailed_period_groups(
period_settings = []
for period in range(num_periods):
intent = effective_intents[period]
- mode = self.INTENT_TO_MODE.get(intent, "load_first")
+ mode = self._effective_mode_for_intent(
+ intent, self.INTENT_TO_MODE.get(intent, "load_first")
+ )
control = self.INTENT_TO_CONTROL.get(
intent,
{"grid_charge": False, "charge_rate": 100, "discharge_rate": 0},
@@ -331,7 +368,9 @@ def get_detailed_period_groups(
"period": period,
"intent": intent,
"mode": mode,
- "grid_charge": control["grid_charge"],
+ "grid_charge": self._effective_grid_charge(
+ intent, control["grid_charge"]
+ ),
"charge_rate": charge_rate,
"discharge_rate": discharge_rate,
"action_kwh": action_kwh,
diff --git a/core/bess/settings.py b/core/bess/settings.py
index b2c9cf45..8f3a778e 100644
--- a/core/bess/settings.py
+++ b/core/bess/settings.py
@@ -129,6 +129,10 @@ class BatterySettings:
)
efficiency_charge: float = BATTERY_EFFICIENCY_CHARGE
efficiency_discharge: float = BATTERY_EFFICIENCY_DISCHARGE
+ # AC-coupled PV opt-in: when True, SOLAR_STORAGE periods enable grid
+ # charging so the battery can AC-charge from surplus solar that flows
+ # back through the meter (no DC solar input on the battery inverter).
+ external_solar_mode: bool = False
reserved_capacity: float = field(init=False)
min_soe_kwh: float = field(init=False)
max_soe_kwh: float = field(init=False)
@@ -176,6 +180,7 @@ def from_ha_config(self, config: dict) -> "BatterySettings":
self.min_action_profit_threshold = battery_config.get(
"min_action_profit_threshold", BATTERY_MIN_ACTION_PROFIT_THRESHOLD
)
+ self.external_solar_mode = battery_config.get("external_solar_mode", False)
self.__post_init__()
return self
diff --git a/core/bess/solax_modbus_growatt_controller.py b/core/bess/solax_modbus_growatt_controller.py
index 5b364ebd..b9d71c16 100644
--- a/core/bess/solax_modbus_growatt_controller.py
+++ b/core/bess/solax_modbus_growatt_controller.py
@@ -183,7 +183,9 @@ def _apply_period_tou(
mode = "load_first"
if current_period < len(self.strategic_intents):
intent = self.strategic_intents[current_period]
- mode = self.INTENT_TO_MODE.get(intent, "load_first")
+ mode = self._effective_mode_for_intent(
+ intent, self.INTENT_TO_MODE.get(intent, "load_first")
+ )
if mode != self._last_written_tou_mode:
enabled = mode != "load_first"
@@ -326,7 +328,9 @@ def write_schedule_to_hardware(
mode = "load_first"
if effective_period < len(self.strategic_intents):
intent = self.strategic_intents[effective_period]
- mode = self.INTENT_TO_MODE.get(intent, "load_first")
+ mode = self._effective_mode_for_intent(
+ intent, self.INTENT_TO_MODE.get(intent, "load_first")
+ )
enabled = mode != "load_first"
logger.info(
@@ -533,7 +537,9 @@ def get_all_tou_segments(self, current_period: int | None = None):
result = []
for group in groups:
- mode = self.INTENT_TO_MODE.get(group["intent"], "load_first")
+ mode = self._effective_mode_for_intent(
+ group["intent"], self.INTENT_TO_MODE.get(group["intent"], "load_first")
+ )
is_current = group["start_period"] <= current_p <= group["end_period"]
result.append(
{
diff --git a/core/bess/tests/unit/test_external_solar_mode.py b/core/bess/tests/unit/test_external_solar_mode.py
new file mode 100644
index 00000000..50cb1fa5
--- /dev/null
+++ b/core/bess/tests/unit/test_external_solar_mode.py
@@ -0,0 +1,128 @@
+"""Tests for the AC-coupled `external_solar_mode` battery setting.
+
+When enabled, the SOLAR_STORAGE strategic intent must translate to
+`grid_charge=True` so the battery can AC-charge from surplus solar that
+returns via the meter (the battery inverter has no DC solar input).
+All other intents must keep their default mapping.
+"""
+
+import pytest
+
+from core.bess.settings import BatterySettings
+from core.bess.solax_controller import SolaxController
+
+
+def _settings(*, external_solar_mode: bool) -> BatterySettings:
+ return BatterySettings(
+ total_capacity=10.0,
+ max_charge_power_kw=5.0,
+ max_discharge_power_kw=5.0,
+ min_soc=15.0,
+ max_soc=95.0,
+ external_solar_mode=external_solar_mode,
+ )
+
+
+class TestExternalSolarModeOverride:
+ def test_default_is_disabled(self) -> None:
+ assert BatterySettings(total_capacity=10.0).external_solar_mode is False
+
+ def test_solar_storage_grid_charge_false_when_disabled(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
+ grid_charge, discharge_rate = ctrl._map_intent_to_rates("SOLAR_STORAGE", 0.0)
+ assert grid_charge is False
+ assert discharge_rate == 0
+
+ def test_solar_storage_grid_charge_true_when_enabled(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ grid_charge, discharge_rate = ctrl._map_intent_to_rates("SOLAR_STORAGE", 0.0)
+ assert grid_charge is True
+ assert discharge_rate == 0
+
+ @pytest.mark.parametrize(
+ "intent,expected_grid_charge",
+ [
+ ("GRID_CHARGING", True),
+ ("LOAD_SUPPORT", False),
+ ("BATTERY_EXPORT", False),
+ ("IDLE", False),
+ ],
+ )
+ def test_other_intents_unaffected_when_enabled(
+ self, intent: str, expected_grid_charge: bool
+ ) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ grid_charge, _ = ctrl._map_intent_to_rates(intent, 0.0)
+ assert grid_charge is expected_grid_charge
+
+ def test_detailed_period_groups_apply_override(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ groups = ctrl.get_detailed_period_groups()
+ assert groups, "expected at least one period group"
+ for group in groups:
+ assert group["grid_charge"] is True
+ assert group["intent"] == "SOLAR_STORAGE"
+
+ def test_detailed_period_groups_no_override_when_disabled(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ groups = ctrl.get_detailed_period_groups()
+ for group in groups:
+ assert group["grid_charge"] is False
+
+ def test_get_period_settings_applies_override_without_schedule(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ ctrl.current_schedule = None
+ settings = ctrl.get_period_settings(period=10)
+ assert settings["grid_charge"] is True
+ assert settings["strategic_intent"] == "SOLAR_STORAGE"
+
+
+class TestExternalSolarModeBattModeOverride:
+ """external_solar_mode should also flip SOLAR_STORAGE's mode to battery_first.
+
+ On AC-coupled setups, Load First mode does not initiate battery charging
+ even with grid_charge enabled — the EMS waits for a trigger that never
+ comes. Battery First mode makes the inverter actively charge from the
+ AC side.
+ """
+
+ def test_solar_storage_mode_is_load_first_when_disabled(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=False))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ settings = ctrl.get_period_settings(period=10)
+ assert settings["batt_mode"] == "load_first"
+
+ def test_solar_storage_mode_is_battery_first_when_enabled(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ settings = ctrl.get_period_settings(period=10)
+ assert settings["batt_mode"] == "battery_first"
+
+ @pytest.mark.parametrize(
+ "intent,expected_mode",
+ [
+ ("GRID_CHARGING", "battery_first"),
+ ("LOAD_SUPPORT", "load_first"),
+ ("BATTERY_EXPORT", "grid_first"),
+ ("IDLE", "load_first"),
+ ],
+ )
+ def test_other_intents_unaffected_when_enabled(
+ self, intent: str, expected_mode: str
+ ) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ ctrl.strategic_intents = [intent] * 96
+ settings = ctrl.get_period_settings(period=10)
+ assert settings["batt_mode"] == expected_mode
+
+ def test_detailed_period_groups_apply_mode_override(self) -> None:
+ ctrl = SolaxController(battery_settings=_settings(external_solar_mode=True))
+ ctrl.strategic_intents = ["SOLAR_STORAGE"] * 96
+ groups = ctrl.get_detailed_period_groups()
+ assert groups, "expected at least one period group"
+ for group in groups:
+ assert group["mode"] == "battery_first"
+ assert group["grid_charge"] is True
diff --git a/frontend/src/components/settings/BatteryFormSection.tsx b/frontend/src/components/settings/BatteryFormSection.tsx
index f37ff9e2..2ead167f 100644
--- a/frontend/src/components/settings/BatteryFormSection.tsx
+++ b/frontend/src/components/settings/BatteryFormSection.tsx
@@ -12,6 +12,7 @@ export interface BatteryForm {
efficiencyDischarge: number;
temperatureDeratingEnabled: boolean;
minActionProfit: number;
+ externalSolarMode: boolean;
}
interface Props {
@@ -54,6 +55,14 @@ export function BatteryFormSection({
+