mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-16 02:51:56 +00:00
fix(planning): accept full SoC targets and clarify unlimited EV SoC age (#1407)
* fix(devices): allow charging targets equal to the maximum SoC * test(planning): protect EV SoC without age limits
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user