Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/api_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion backend/tests/test_settings_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 44 additions & 5 deletions core/bess/inverter_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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"]

Expand Down Expand Up @@ -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},
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions core/bess/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions core/bess/solax_modbus_growatt_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
{
Expand Down
128 changes: 128 additions & 0 deletions core/bess/tests/unit/test_external_solar_mode.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions frontend/src/components/settings/BatteryFormSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface BatteryForm {
efficiencyDischarge: number;
temperatureDeratingEnabled: boolean;
minActionProfit: number;
externalSolarMode: boolean;
}

interface Props {
Expand Down Expand Up @@ -54,6 +55,14 @@ export function BatteryFormSection({
</div>
</SectionCard>

<SectionCard
title="PV coupling"
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."
>
{toggle('External solar mode (AC-coupled PV)', form.externalSolarMode,
v => onChange({ ...form, externalSolarMode: v }))}
</SectionCard>

{/* Advanced settings collapsible — hidden in wizard mode since these
fields are not sent by the wizard completion payload */}
{!hideAdvanced && <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const EMPTY_BATTERY: BatteryForm = {
cycleCostPerKwh: 0,
efficiencyCharge: 97, efficiencyDischarge: 97,
temperatureDeratingEnabled: false, minActionProfit: 0,
externalSolarMode: false,
};
const EMPTY_HOME: HomeForm = {
consumption: 3.5, consumptionStrategy: 'sensor',
Expand Down Expand Up @@ -170,6 +171,7 @@ const SettingsPage: React.FC = () => {
efficiencyDischarge: bat_s.efficiencyDischarge ?? 0.95,
temperatureDeratingEnabled: bat_s.temperatureDerating?.enabled ?? false,
minActionProfit: bat_s.minActionProfitThreshold ?? 0,
externalSolarMode: bat_s.externalSolarMode ?? false,
};
setBatteryForm(bat);
savedBattery.current = JSON.stringify(bat);
Expand Down Expand Up @@ -452,6 +454,7 @@ const SettingsPage: React.FC = () => {
enabled: batteryForm.temperatureDeratingEnabled,
weatherEntity: sensors.shared?.['weather_entity'] ?? '',
},
externalSolarMode: batteryForm.externalSolarMode,
},
growatt: {
deviceId: inverterForm.deviceId,
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/SetupWizardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const SetupWizardPage: React.FC = () => {
efficiencyDischarge: 97,
temperatureDeratingEnabled: false,
minActionProfit: 8.0,
externalSolarMode: false,
});

const [inverterForm, setInverterForm] = useState<InverterForm>({
Expand Down Expand Up @@ -234,6 +235,7 @@ const SetupWizardPage: React.FC = () => {
efficiencyCharge: bat.efficiencyCharge ?? f.efficiencyCharge,
efficiencyDischarge: bat.efficiencyDischarge ?? f.efficiencyDischarge,
temperatureDeratingEnabled: bat.temperatureDeratingEnabled ?? f.temperatureDeratingEnabled,
externalSolarMode: bat.externalSolarMode ?? f.externalSolarMode,
}));
setHomeForm(f => ({
...f,
Expand Down Expand Up @@ -310,6 +312,7 @@ const SetupWizardPage: React.FC = () => {
maxChargeDischargePower: batteryForm.maxChargeDischargePowerKw,
cycleCost: batteryForm.cycleCostPerKwh,
minActionProfitThreshold: batteryForm.minActionProfit,
externalSolarMode: batteryForm.externalSolarMode,
// Home
currency: pricingForm.currency,
consumption: homeForm.consumption,
Expand Down
Loading