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({ + + {toggle('External solar mode (AC-coupled PV)', form.externalSolarMode, + v => onChange({ ...form, externalSolarMode: v }))} + + {/* Advanced settings collapsible — hidden in wizard mode since these fields are not sent by the wizard completion payload */} {!hideAdvanced &&
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 5812ae43..d002672c 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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', @@ -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); @@ -452,6 +454,7 @@ const SettingsPage: React.FC = () => { enabled: batteryForm.temperatureDeratingEnabled, weatherEntity: sensors.shared?.['weather_entity'] ?? '', }, + externalSolarMode: batteryForm.externalSolarMode, }, growatt: { deviceId: inverterForm.deviceId, diff --git a/frontend/src/pages/SetupWizardPage.tsx b/frontend/src/pages/SetupWizardPage.tsx index 3d94b58e..112879ba 100644 --- a/frontend/src/pages/SetupWizardPage.tsx +++ b/frontend/src/pages/SetupWizardPage.tsx @@ -53,6 +53,7 @@ const SetupWizardPage: React.FC = () => { efficiencyDischarge: 97, temperatureDeratingEnabled: false, minActionProfit: 8.0, + externalSolarMode: false, }); const [inverterForm, setInverterForm] = useState({ @@ -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, @@ -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, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3f62d4c0..e2b48dd8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -116,6 +116,9 @@ export interface BatterySettings { efficiencyCharge: number; // % charging efficiency efficiencyDischarge: number; // % discharge efficiency + // AC-coupled PV: route SOLAR_STORAGE through grid charging + externalSolarMode?: boolean; + // Consumption estimate estimatedConsumption: number; // kWh daily estimate consumptionStrategy: string; // "sensor", "fixed", or "influxdb_7d_avg"