diff --git a/docs/akkudoktoreos/adapter/adapternodered.md b/docs/akkudoktoreos/adapter/adapternodered.md index ca6b95ac..fada6e48 100644 --- a/docs/akkudoktoreos/adapter/adapternodered.md +++ b/docs/akkudoktoreos/adapter/adapternodered.md @@ -100,6 +100,62 @@ EOS does **not** directly control devices. It provides **structured optimisation results**, while your tool of choice remains responsible for executing the actual control actions. +## 5. Calling the planner from an external Node-RED schedule + +An existing Node-RED scheduler can call `POST /v1/optimize` with device SoC and +forecast overrides. Hardware settings and the desired charging target remain in EOS +configuration. See the [upgrade guide](../upgrade-genetic.md) for the request contract. + +### Reuse the latest available EV SoC + +Some vehicle integrations store a new database row only when the battery percentage +changes. A parked vehicle can therefore have an older value-change timestamp even +when the database is being read regularly. A fixed 15-minute age limit on that row +can prevent both EV and household planning indefinitely. + +Pass the latest available valid EV percentage explicitly in the request's `soc` map +when that is the intended source policy. EOS accepts explicit SoC without requiring +a separate fresh measurement. There is no maximum age for the explicitly supplied +EV SoC, whether measured or read minutes, days or months ago. Keep the latest available +valid stored value; neither its source timestamp nor its last database-read time +should block planning because of age. + +Keep the database's original timestamp for diagnostics; rereading the same row does +not make it a newly observed measurement. Validate that the percentage exists and is +within 0 to 100. Apply any stationary-battery freshness policy independently of this +EV value-change timestamp. + +### Preserve the charging target when deploying a flow + +Store the desired EV charging target in Node-RED flow or node context using +`flow.set()` or `context.set()`, and restore it when the request function initializes. +A property assigned directly to a Function node's `context` wrapper is not stored +through the context API and can be lost when that node is redeployed. Choose a +persistent context store if values must also survive a Node-RED process restart. +Reload the saved target instead of silently defaulting an active order to zero. + +With `max_soc_percentage = 100`, a target of `min_soc_percentage = 100` is valid. +Remove an obsolete client-side restriction to values below 100 rather than lowering +the requested target before sending it to EOS. + +### Recover missing forecasts after an EOS restart + +Prediction retrieval can fail or return no data while EOS is restarting or a provider +is unavailable. An external scheduler that waits for complete raw price coverage +should route both unsuccessful HTTP reads and empty successful reads through its +existing prediction-recovery path. That path calls `POST /v1/prediction/update`, waits +for completion, then reloads the forecast series and checks coverage again. + +Rate-limit recovery attempts, for example to at most once every five minutes, and +avoid starting a second run while optimization is busy. A successful update HTTP +response alone does not establish complete forecasts; providers can still report +failures in the EOS log. Only resume planning after the required series cover the +control period. Preserve the site's interval, time zone and actual source timestamps +when checking raw prices, including daylight-saving transition days. + +Prediction recovery must not send device commands or change operator permissions. +Keep existing handling for failed or stale plans until a new successful plan arrives. + ### Summary * **EOS** focuses on **forecasting, simulation, and optimisation** diff --git a/docs/akkudoktoreos/upgrade-genetic.md b/docs/akkudoktoreos/upgrade-genetic.md index ccbfc777..d103bf7f 100644 --- a/docs/akkudoktoreos/upgrade-genetic.md +++ b/docs/akkudoktoreos/upgrade-genetic.md @@ -81,6 +81,12 @@ continuation is allowed and reported. Failed calls return an error; they no long like a successful new run by returning an older result. Existing automations should handle errors and verify the timestamp of any separately retrieved stored plan. +A charging target may equal the configured maximum SoC. For example, +`devices.electric_vehicles.car.min_soc_percentage = 100` is valid when +`max_soc_percentage = 100`. A target above the maximum remains invalid. +The target is a planning objective; the achievable SoC also depends on the available +time, charging power and supported charge rates. + ### 4. Check units, timestamps and result consumers - Runtime forecast arrays start at local midnight and use the configured slot interval. diff --git a/src/akkudoktoreos/devices/settings/batterysettings.py b/src/akkudoktoreos/devices/settings/batterysettings.py index bdc26b65..50791bfe 100644 --- a/src/akkudoktoreos/devices/settings/batterysettings.py +++ b/src/akkudoktoreos/devices/settings/batterysettings.py @@ -315,8 +315,8 @@ class BatteriesCommonSettings(DevicesBaseSettings): @model_validator(mode="after") def _validate_soc_range(self) -> "BatteriesCommonSettings": - if self.min_soc_percentage >= self.max_soc_percentage: - raise ValueError("min_soc_percentage must be < max_soc_percentage") + if self.min_soc_percentage > self.max_soc_percentage: + raise ValueError("min_soc_percentage must be <= max_soc_percentage") if ( self.min_charge_power_w is not None and self.max_charge_power_w is not None diff --git a/tests/test_battery.py b/tests/test_battery.py index 27d06491..77429d75 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -398,3 +398,33 @@ def test_pv_converter_preserves_id_lcos_charge_and_export_rates(): assert parameters.levelized_cost_of_storage_kwh == pytest.approx(0.123) battery = Battery(parameters, prediction_hours=4, slot_duration_h=0.25) assert battery.levelized_cost_of_storage_kwh == pytest.approx(0.123) + + +@pytest.mark.parametrize("target", [0, 50, 100]) +def test_equal_soc_limits_are_valid(target): + settings = BatteriesCommonSettings(device_id="ev", min_soc_percentage=target, + max_soc_percentage=target) + assert settings.to_genetic_ev_bat_param().min_soc_percentage == target + assert settings.to_genetic0_ev_bat_param().min_soc_percentage == target + + +@pytest.mark.parametrize("minimum,maximum", [(100, 99), (81, 80), (101, 100), (-1, 100)]) +def test_inverted_or_out_of_range_limits_still_fail(minimum, maximum): + with pytest.raises(ValidationError): + BatteriesCommonSettings(device_id="ev", min_soc_percentage=minimum, + max_soc_percentage=maximum) + + +def test_full_target_charges_to_capacity_without_overcharge(): + settings = BatteriesCommonSettings(device_id="ev", capacity_wh=1000, + min_soc_percentage=100, max_soc_percentage=100, max_charge_power_w=1000, + charging_efficiency=1) + params = settings.to_genetic_ev_bat_param() + params.initial_soc_percentage = 70 + battery = Battery(params, prediction_hours=4, slot_duration_h=0.25) + battery.charge_array = np.ones(4) + for slot in range(4): + battery.charge_energy(wh=1000, hour=slot) + assert battery.current_soc_percentage() <= 100 + assert battery.current_soc_percentage() == pytest.approx(100) + assert battery.charge_energy(wh=1000, hour=3) == (0, 0) diff --git a/tests/test_config_optimization_request.py b/tests/test_config_optimization_request.py index 14c5d847..76e19bfc 100644 --- a/tests/test_config_optimization_request.py +++ b/tests/test_config_optimization_request.py @@ -451,3 +451,66 @@ async def test_missing_ev_soc_does_not_replace_explicit_battery_soc(configured_r assert parameters.ev is not None assert parameters.pv_battery.initial_soc_percentage == 42 assert parameters.ev.initial_soc_percentage == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("soc", [70, 100]) +async def test_http_config_and_real_optimizer_allow_full_ev_target(configured_request, + monkeypatch, soc): + from akkudoktoreos.server import eos + + config, fake, _, data = configured_request + config.merge_settings_from_dict({"devices": {"max_electric_vehicles": 1, + "electric_vehicles": {"ev": {"capacity_wh": 1000, "max_charge_power_w": 1000, + "charging_efficiency": 1, "min_soc_percentage": 0, + "max_soc_percentage": 100}}}}) + data["soc"]["ev"] = soc + ems = EnergyManagement() + monkeypatch.setattr(configrequest, "get_ems", lambda: ems) + monkeypatch.setattr(EnergyManagement, "_genetic_solution", None) + monkeypatch.setattr(EnergyManagement, "prediction", Mock(update_data=AsyncMock())) + monkeypatch.setattr(EnergyManagement, "adapter", Mock(update_data=AsyncMock())) + + async def run(**kwargs): + return await ems.run(start_datetime=fake.start_datetime, **kwargs) + + from types import SimpleNamespace + monkeypatch.setattr(eos, "get_ems", lambda: SimpleNamespace(run=run)) + monkeypatch.setattr(eos, "get_config", lambda: config) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=eos.app), + base_url="http://test") as client: + updated = await client.put("/v1/config/devices/electric_vehicles/ev/min_soc_percentage", + json=100) + assert updated.status_code == 200, updated.text + assert config.devices.electric_vehicles["ev"].min_soc_percentage == 100 + result = await client.post("/v1/optimize", json=data) + assert result.status_code == 200, result.text + plan = result.json() + assert plan["parameters"]["ev"]["min_soc_percentage"] == 100 + assert plan["parameters"]["ev"]["initial_soc_percentage"] == soc + assert len(plan["result"]["ev_soc_per_hour"]) == 4 + assert all(0 <= value <= 100 for value in plan["result"]["ev_soc_per_hour"]) + assert plan["result"]["ev_soc_per_hour"][-1] == pytest.approx(100) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("age_seconds", [901, 86400, 365 * 86400]) +async def test_explicit_ev_soc_has_no_measurement_age_limit(configured_request, age_seconds): + config, ems, measurement, data = configured_request + config.merge_settings_from_dict( + {"devices": {"max_electric_vehicles": 1, + "electric_vehicles": {"car": {"capacity_wh": 60000, + "min_soc_percentage": 100, + "max_soc_percentage": 100}}}} + ) + old_sample = ems.observation_datetime.subtract(seconds=age_seconds) + measurement.key_to_lists.return_value = ([old_sample], [0.35]) + data["soc"]["car"] = 70 + config_before = config.model_dump_json() + parameters = await ConfigOptimizationRequest.model_validate(data).resolve() + assert parameters.ev is not None + assert parameters.ev.initial_soc_percentage == 70 + assert parameters.ev.min_soc_percentage == 100 + measurement.key_to_lists.assert_not_awaited() + measurement.update_value.assert_not_called() + assert config.model_dump_json() == config_before