mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-09 15:24:01 +01:00
feat(battery): plan a periodic full charge of the stationary battery (#1486)
LFP batteries can only recalibrate their state of charge close to full charge, so many systems expect a full charge every few weeks and some start one on their own at an arbitrary, possibly expensive time. EOS can now plan it instead (GENETIC only, off by default). New battery settings: full_charge_interval_days (unset = off), full_charge_soc_percentage, full_charge_detect_percentage, full_charge_allow_grid and the informational cell_chemistry. The last full charge is read from the measured SoC history; nothing is stored. When the next one is due within the horizon (or overdue), the maximum SoC is raised to the target. With full_charge_allow_grid the optimizer must reach the target once before the deadline, which is at least 24 h away (penalty battery_full_charge_miss on the best SoC reached), so it picks the cheapest time itself. Without it, PV surplus may fill the battery but the target is not enforced. Signed-off-by: SchaafAlexander <86607129+SchaafAlexander@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b33953d445
commit
a06473b6d6
@@ -46,6 +46,10 @@ compatibility changes and a short test procedure. These changes are not yet a ta
|
||||
- Optional size limit for the GENETIC fitness cache: `optimization.genetic.fitness_cache_max_entries`
|
||||
(`null` = unbounded, the default; `0` = off; N = at most N keys). Bounding it lowers peak memory of
|
||||
a run by up to ~150 MB on small devices without changing the plan.
|
||||
- Optional periodic full charge of a stationary battery for GENETIC, e.g. to recalibrate an LFP
|
||||
battery: `full_charge_interval_days` (unset = off, the default), `full_charge_soc_percentage`,
|
||||
`full_charge_detect_percentage`, `full_charge_allow_grid` and the informational
|
||||
`cell_chemistry`. The last full charge is taken from the measured state of charge.
|
||||
- Optional `general.timezone_override`: an IANA timezone name used as `general.timezone`
|
||||
instead of the lookup from latitude/longitude. Unset (default), the lookup is unchanged.
|
||||
- `GET /v1/health` reports `started_at`, the start time of the server process, so a client
|
||||
|
||||
@@ -65,7 +65,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
@@ -102,7 +107,12 @@ config path from ``self.device_id`` without needing an external index.
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
@@ -174,6 +184,11 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false,
|
||||
"measurement_key_soc_factor": "bat0-soc-factor",
|
||||
"measurement_key_power_l1_w": "bat0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "bat0-power-l2-w",
|
||||
@@ -223,6 +238,11 @@ config path from ``self.device_id`` without needing an external index.
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false,
|
||||
"measurement_key_soc_factor": "ev0-soc-factor",
|
||||
"measurement_key_power_l1_w": "ev0-power-l1-w",
|
||||
"measurement_key_power_l2_w": "ev0-power-l2-w",
|
||||
|
||||
@@ -69,7 +69,12 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false
|
||||
}
|
||||
},
|
||||
"max_batteries": 1,
|
||||
@@ -106,7 +111,12 @@
|
||||
1.0
|
||||
],
|
||||
"min_soc_deadline_datetime": null,
|
||||
"min_soc_max_duration_h": null
|
||||
"min_soc_max_duration_h": null,
|
||||
"cell_chemistry": null,
|
||||
"full_charge_interval_days": null,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_detect_percentage": 99,
|
||||
"full_charge_allow_grid": false
|
||||
}
|
||||
},
|
||||
"max_electric_vehicles": 1,
|
||||
|
||||
@@ -213,6 +213,11 @@ The behavior of the genetic algorithm can be customized using the following conf
|
||||
A value of `1.0` (default) means the penalty equals the actual economic loss in €.
|
||||
Use larger values (e.g. `3.0`) to make the optimizer more aggressively avoid unprofitable
|
||||
AC charging, or `0.0` to disable this penalty entirely.
|
||||
|
||||
- `battery_full_charge_miss`:
|
||||
Applies a penalty per percent the battery stays below its target when a periodic full
|
||||
charge is due and enforced (see [Periodic full charge](#periodic-full-charge)).
|
||||
Default: `10`.
|
||||
:::
|
||||
|
||||
#### Value Formats
|
||||
@@ -384,6 +389,57 @@ The inverter supports separate AC↔DC conversion efficiencies:
|
||||
}
|
||||
```
|
||||
|
||||
##### Periodic full charge
|
||||
|
||||
LFP batteries have an almost flat voltage curve in the middle of their range. The battery
|
||||
management system can only recalibrate the state of charge close to full charge, so many
|
||||
manufacturers expect a full charge every few weeks, and some batteries start one on their own
|
||||
at an arbitrary time. A periodic full charge lets EOS plan it instead, preferably from PV
|
||||
surplus or at a cheap price.
|
||||
|
||||
The feature is off by default. It is enabled per stationary battery with
|
||||
`full_charge_interval_days` (GENETIC only):
|
||||
|
||||
- `full_charge_interval_days`: maximum number of days between two full charges. `null` (default)
|
||||
disables the feature. For LFP batteries 21 to 25 days are a reasonable start; check what the
|
||||
manufacturer requires.
|
||||
- `full_charge_soc_percentage` (default `100`): the target. It may be above `max_soc_percentage`,
|
||||
which keeps applying on all other days.
|
||||
- `full_charge_detect_percentage` (default `99`): the measured state of charge from which the
|
||||
battery counts as full. Many battery management systems never report exactly 100 %.
|
||||
- `full_charge_allow_grid` (default `false`): with `false`, EOS only raises the maximum state of
|
||||
charge while the full charge is due, so that PV surplus can fill the battery; the target is not
|
||||
enforced. With `true`, the optimizer has to reach the target and charges from the grid if
|
||||
needed, at the cheapest time before the deadline.
|
||||
- `cell_chemistry` (`LFP`, `NMC`, `NCA`, `LTO` or `null`): informational only, it does not
|
||||
enable anything.
|
||||
|
||||
The last full charge is taken from the measured state of charge of the battery
|
||||
(`<device_id>-soc-factor`), so a full charge from PV or one the battery started itself counts as
|
||||
well. Nothing else is stored. Without a full charge in the last interval, the interval counts
|
||||
from the oldest measurement, so a new installation is not forced into a full charge on its first
|
||||
day. The next full charge is due one interval after the last one. EOS acts once that deadline
|
||||
falls within the optimization horizon or has passed, and logs an INFO message for every run in
|
||||
which the full charge is due. The optimizer always gets at least 24 hours to finish it, so that
|
||||
a close or overdue full charge can still use the cheapest time.
|
||||
|
||||
```json
|
||||
{
|
||||
"devices": {
|
||||
"batteries": {
|
||||
"battery1": {
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"max_soc_percentage": 90,
|
||||
"cell_chemistry": "LFP",
|
||||
"full_charge_interval_days": 21,
|
||||
"full_charge_allow_grid": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Home appliance simulation configuration
|
||||
|
||||
**Example:**
|
||||
|
||||
+207
@@ -3682,6 +3682,92 @@
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"cell_chemistry": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"LFP",
|
||||
"NMC",
|
||||
"NCA",
|
||||
"LTO"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Cell Chemistry",
|
||||
"description": "Cell chemistry of the battery. Informational only, it does not enable anything. LFP batteries usually need a periodic full charge to recalibrate their state of charge, see 'full_charge_interval_days'.",
|
||||
"examples": [
|
||||
null,
|
||||
"LFP"
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_interval_days": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Full Charge Interval Days",
|
||||
"description": "Maximum number of days between two full charges of a stationary battery [d]. The last full charge is taken from the measured state of charge. When the next one falls due within the optimization horizon, the maximum state of charge is raised to 'full_charge_soc_percentage'. None disables the periodic full charge. Ignored for electric vehicles.",
|
||||
"examples": [
|
||||
null,
|
||||
21
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_soc_percentage": {
|
||||
"type": "integer",
|
||||
"maximum": 100.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Full Charge Soc Percentage",
|
||||
"description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days.",
|
||||
"default": 100,
|
||||
"examples": [
|
||||
100
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_detect_percentage": {
|
||||
"type": "integer",
|
||||
"maximum": 100.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Full Charge Detect Percentage",
|
||||
"description": "Measured state of charge from which the battery counts as fully charged [%]. Many battery management systems never report exactly 100 %.",
|
||||
"default": 99,
|
||||
"examples": [
|
||||
99
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_allow_grid": {
|
||||
"type": "boolean",
|
||||
"title": "Full Charge Allow Grid",
|
||||
"description": "Enforce a due full charge, charging from the grid if needed. False only raises the maximum state of charge, so that PV surplus can fill the battery, without enforcing the target.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3930,6 +4016,92 @@
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"cell_chemistry": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"LFP",
|
||||
"NMC",
|
||||
"NCA",
|
||||
"LTO"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Cell Chemistry",
|
||||
"description": "Cell chemistry of the battery. Informational only, it does not enable anything. LFP batteries usually need a periodic full charge to recalibrate their state of charge, see 'full_charge_interval_days'.",
|
||||
"examples": [
|
||||
null,
|
||||
"LFP"
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_interval_days": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Full Charge Interval Days",
|
||||
"description": "Maximum number of days between two full charges of a stationary battery [d]. The last full charge is taken from the measured state of charge. When the next one falls due within the optimization horizon, the maximum state of charge is raised to 'full_charge_soc_percentage'. None disables the periodic full charge. Ignored for electric vehicles.",
|
||||
"examples": [
|
||||
null,
|
||||
21
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_soc_percentage": {
|
||||
"type": "integer",
|
||||
"maximum": 100.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Full Charge Soc Percentage",
|
||||
"description": "State of charge a periodic full charge has to reach [%]. May be above 'max_soc_percentage', which then applies on the other days.",
|
||||
"default": 100,
|
||||
"examples": [
|
||||
100
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_detect_percentage": {
|
||||
"type": "integer",
|
||||
"maximum": 100.0,
|
||||
"minimum": 1.0,
|
||||
"title": "Full Charge Detect Percentage",
|
||||
"description": "Measured state of charge from which the battery counts as fully charged [%]. Many battery management systems never report exactly 100 %.",
|
||||
"default": 99,
|
||||
"examples": [
|
||||
99
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"full_charge_allow_grid": {
|
||||
"type": "boolean",
|
||||
"title": "Full Charge Allow Grid",
|
||||
"description": "Enforce a due full charge, charging from the grid if needed. False only raises the maximum state of charge, so that PV surplus can fill the battery, without enforcing the target.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"x-scope": [
|
||||
"GENETIC"
|
||||
]
|
||||
},
|
||||
"measurement_key_soc_factor": {
|
||||
"type": "string",
|
||||
"title": "Measurement Key Soc Factor",
|
||||
@@ -16814,6 +16986,41 @@
|
||||
"examples": [
|
||||
0.12
|
||||
]
|
||||
},
|
||||
"full_charge_soc_percentage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 100.0,
|
||||
"minimum": 1.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Full Charge Soc Percentage",
|
||||
"description": "State of charge a due full charge has to reach at least once before 'full_charge_deadline_datetime' [%]. Must not exceed 'max_soc_percentage'. None means no full charge is due.",
|
||||
"examples": [
|
||||
null,
|
||||
100
|
||||
]
|
||||
},
|
||||
"full_charge_deadline_datetime": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Full Charge Deadline Datetime",
|
||||
"description": "Moment by which 'full_charge_soc_percentage' has to be reached. A date time without timezone is read as local time. None means end of the optimization horizon.",
|
||||
"examples": [
|
||||
null,
|
||||
"2026-11-20T07:00:00+01:00"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
|
||||
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
|
||||
@@ -110,6 +110,48 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
|
||||
},
|
||||
)
|
||||
max_charge_power_w: Optional[float] = max_charging_power_field()
|
||||
full_charge_soc_percentage: Optional[int] = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
le=100,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"State of charge a due full charge has to reach at least once "
|
||||
"before 'full_charge_deadline_datetime' [%]. Must not exceed "
|
||||
"'max_soc_percentage'. None means no full charge is due."
|
||||
),
|
||||
"examples": [None, 100],
|
||||
},
|
||||
)
|
||||
full_charge_deadline_datetime: Optional[DateTime] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Moment by which 'full_charge_soc_percentage' has to be reached. "
|
||||
"A date time without timezone is read as local time. None means "
|
||||
"end of the optimization horizon."
|
||||
),
|
||||
"examples": [None, "2026-11-20T07:00:00+01:00"],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("full_charge_deadline_datetime", mode="before")
|
||||
@classmethod
|
||||
def transform_full_charge_deadline(cls, value: Any) -> Optional[DateTime]:
|
||||
"""Accept the usual date time representations, naive input is local time."""
|
||||
if value is None:
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_full_charge_target(self) -> "SolarPanelBatteryParameters":
|
||||
"""A full charge target above the maximum SoC could never be reached."""
|
||||
if (
|
||||
self.full_charge_soc_percentage is not None
|
||||
and self.full_charge_soc_percentage > self.max_soc_percentage
|
||||
):
|
||||
raise ValueError("full_charge_soc_percentage must be <= max_soc_percentage")
|
||||
return self
|
||||
|
||||
|
||||
class ElectricVehicleParameters(BaseBatteryParameters):
|
||||
|
||||
@@ -4,7 +4,7 @@ Note: Used for the GENETIC and GENETIC0 algorithm.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpydantic import NDArray, Shape
|
||||
@@ -212,6 +212,80 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
||||
return None
|
||||
return to_datetime(value)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Periodic full charge (stationary batteries, GENETIC only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
cell_chemistry: Optional[Literal["LFP", "NMC", "NCA", "LTO"]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Cell chemistry of the battery. Informational only, it does not "
|
||||
"enable anything. LFP batteries usually need a periodic full charge "
|
||||
"to recalibrate their state of charge, see "
|
||||
"'full_charge_interval_days'."
|
||||
),
|
||||
"examples": [None, "LFP"],
|
||||
},
|
||||
)
|
||||
full_charge_interval_days: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
allow_inf_nan=False,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Maximum number of days between two full charges of a stationary "
|
||||
"battery [d]. The last full charge is taken from the measured state "
|
||||
"of charge. When the next one falls due within the optimization "
|
||||
"horizon, the maximum state of charge is raised to "
|
||||
"'full_charge_soc_percentage'. None disables the periodic full "
|
||||
"charge. Ignored for electric vehicles."
|
||||
),
|
||||
"examples": [None, 21],
|
||||
},
|
||||
)
|
||||
full_charge_soc_percentage: int = Field(
|
||||
default=100,
|
||||
ge=1,
|
||||
le=100,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"State of charge a periodic full charge has to reach [%]. May be "
|
||||
"above 'max_soc_percentage', which then applies on the other days."
|
||||
),
|
||||
"examples": [100],
|
||||
},
|
||||
)
|
||||
full_charge_detect_percentage: int = Field(
|
||||
default=99,
|
||||
ge=1,
|
||||
le=100,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Measured state of charge from which the battery counts as fully "
|
||||
"charged [%]. Many battery management systems never report exactly "
|
||||
"100 %."
|
||||
),
|
||||
"examples": [99],
|
||||
},
|
||||
)
|
||||
full_charge_allow_grid: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"x-scope": [str(ConfigScope.GENETIC)],
|
||||
"description": (
|
||||
"Enforce a due full charge, charging from the grid if needed. "
|
||||
"False only raises the maximum state of charge, so that PV surplus "
|
||||
"can fill the battery, without enforcing the target."
|
||||
),
|
||||
"examples": [False, True],
|
||||
},
|
||||
)
|
||||
|
||||
def to_genetic_pv_bat_param(self) -> "SolarPanelBatteryParameters":
|
||||
"""Return SolarPanelBatteryParameters for the GENETIC optimizer."""
|
||||
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
|
||||
@@ -317,6 +391,8 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
||||
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.full_charge_detect_percentage > self.full_charge_soc_percentage:
|
||||
raise ValueError("full_charge_detect_percentage must be <= full_charge_soc_percentage")
|
||||
if (
|
||||
self.min_charge_power_w is not None
|
||||
and self.max_charge_power_w is not None
|
||||
|
||||
@@ -15,6 +15,10 @@ from akkudoktoreos.core.coreabc import (
|
||||
)
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.fullcharge import (
|
||||
enforced_full_charge_deadline,
|
||||
full_charge_deadline,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticEnergyManagementParameters,
|
||||
@@ -133,11 +137,64 @@ class ConfigOptimizationRequest(
|
||||
)
|
||||
return 0
|
||||
|
||||
async def full_charge(device: BatteriesCommonSettings) -> Optional[DateTime]:
|
||||
"""Deadline of the next periodic full charge, None if disabled or unknown."""
|
||||
interval_days = device.full_charge_interval_days
|
||||
if interval_days is None:
|
||||
return None
|
||||
try:
|
||||
dates, values = await self.measurement.key_to_lists(
|
||||
key=device.measurement_key_soc_factor,
|
||||
start_datetime=observation_time.subtract(days=math.ceil(interval_days)),
|
||||
end_datetime=observation_time.add(seconds=1),
|
||||
dropna=False,
|
||||
)
|
||||
except KeyError:
|
||||
dates, values = [], []
|
||||
deadline = full_charge_deadline(
|
||||
zip(dates, values),
|
||||
now=observation_time,
|
||||
interval_days=interval_days,
|
||||
detect_percentage=device.full_charge_detect_percentage,
|
||||
)
|
||||
if deadline is None:
|
||||
logger.warning(
|
||||
"No state of charge history for {}; cannot schedule its periodic full charge.",
|
||||
device.device_id,
|
||||
)
|
||||
return deadline
|
||||
|
||||
battery = None
|
||||
if groups["batteries"]:
|
||||
device = groups["batteries"][0]
|
||||
battery = device.to_genetic_pv_bat_param()
|
||||
battery.initial_soc_percentage = await state(device)
|
||||
deadline = await full_charge(device)
|
||||
horizon_end = start.add(seconds=settings.horizon * settings.interval_sec)
|
||||
if deadline is not None and deadline.timestamp() <= horizon_end.timestamp():
|
||||
# Due within the horizon: lift the upper limit so the battery may
|
||||
# reach the target, and enforce the target only if grid charging
|
||||
# is allowed for it.
|
||||
target = device.full_charge_soc_percentage
|
||||
battery.max_soc_percentage = max(battery.max_soc_percentage, target)
|
||||
if device.full_charge_allow_grid:
|
||||
enforced = enforced_full_charge_deadline(deadline, observation_time)
|
||||
battery.full_charge_soc_percentage = target
|
||||
battery.full_charge_deadline_datetime = enforced
|
||||
logger.info(
|
||||
"Periodic full charge of {} to {}% due by {}; enforced by {}.",
|
||||
device.device_id,
|
||||
target,
|
||||
deadline,
|
||||
enforced,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Periodic full charge of {} to {}% due by {}; PV surplus only.",
|
||||
device.device_id,
|
||||
target,
|
||||
deadline,
|
||||
)
|
||||
ev = None
|
||||
if groups["electric_vehicles"]:
|
||||
device = groups["electric_vehicles"][0]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Periodic full charge of a stationary battery.
|
||||
|
||||
LFP batteries can only recalibrate their state of charge close to full charge,
|
||||
so many battery management systems expect a full charge every few weeks. The
|
||||
last full charge is read from the measured state of charge; nothing is stored.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime
|
||||
|
||||
# A due full charge never has to be finished in less time than this, so the
|
||||
# optimizer can still pick a cheap slot even when the deadline is close or
|
||||
# already overdue.
|
||||
FULL_CHARGE_MIN_LEAD_HOURS = 24
|
||||
|
||||
|
||||
def full_charge_deadline(
|
||||
samples: Iterable[tuple[DateTime, Any]],
|
||||
now: DateTime,
|
||||
interval_days: float,
|
||||
detect_percentage: float,
|
||||
) -> Optional[DateTime]:
|
||||
"""Return the moment by which the next full charge is due.
|
||||
|
||||
Args:
|
||||
samples: Measured state of charge as (time, factor 0.0 ... 1.0) pairs,
|
||||
covering at least the last ``interval_days`` before ``now``. Samples
|
||||
after ``now`` and invalid values are ignored.
|
||||
now: Current time.
|
||||
interval_days: Maximum number of days between two full charges.
|
||||
detect_percentage: State of charge from which a sample counts as full [%].
|
||||
|
||||
Returns:
|
||||
The deadline, which may already have passed, or None if there is no
|
||||
valid sample at all.
|
||||
|
||||
Without a full charge among the samples, the interval counts from the oldest
|
||||
sample. A history that covers the whole interval therefore makes the full
|
||||
charge due now, while a fresh installation is not forced into a full charge
|
||||
on its first day.
|
||||
"""
|
||||
interval = timedelta(days=interval_days)
|
||||
valid = [
|
||||
(date, value)
|
||||
for date, value in samples
|
||||
if value is not None
|
||||
and isinstance(value, (int, float))
|
||||
and math.isfinite(value)
|
||||
and date.timestamp() <= now.timestamp()
|
||||
]
|
||||
if not valid:
|
||||
return None
|
||||
|
||||
full = [date for date, value in valid if value * 100 >= detect_percentage - 1e-9]
|
||||
if full:
|
||||
reference = max(full, key=lambda date: date.timestamp())
|
||||
else:
|
||||
reference = min((date for date, _ in valid), key=lambda date: date.timestamp())
|
||||
|
||||
return reference + interval
|
||||
|
||||
|
||||
def enforced_full_charge_deadline(deadline: DateTime, now: DateTime) -> DateTime:
|
||||
"""Deadline handed to the optimizer, at least the minimum lead time from now.
|
||||
|
||||
Whether a full charge is due is decided on the real deadline. Only the
|
||||
moment it has to be finished by is moved, so that a close or overdue full
|
||||
charge can still use the cheapest slot of the next hours.
|
||||
|
||||
Args:
|
||||
deadline: Deadline from ``full_charge_deadline``.
|
||||
now: Current time.
|
||||
|
||||
Returns:
|
||||
``deadline``, or ``FULL_CHARGE_MIN_LEAD_HOURS`` after ``now`` if that is later.
|
||||
"""
|
||||
earliest = now.add(hours=FULL_CHARGE_MIN_LEAD_HOURS)
|
||||
if deadline.timestamp() < earliest.timestamp():
|
||||
return earliest
|
||||
return deadline
|
||||
@@ -1082,6 +1082,10 @@ class GeneticOptimization(OptimizationBase):
|
||||
# Slot by which the EV has to reach its target SoC. None means the SoC is
|
||||
# only required at the end of the horizon (the behaviour without a deadline).
|
||||
self._ev_soc_deadline_slot: Optional[int] = None
|
||||
# Slot by which a due periodic full charge of the stationary battery has
|
||||
# to have happened. None means no full charge is enforced in this run.
|
||||
self._full_charge_deadline_slot: Optional[int] = None
|
||||
self._full_charge_target_soc: Optional[int] = None
|
||||
# Value of the energy left in the battery at the end of the
|
||||
# horizon. None means the fixed scalar terminal value is used instead.
|
||||
self._terminal_value_curve: Optional[TerminalValueCurve] = None
|
||||
@@ -1236,6 +1240,32 @@ class GeneticOptimization(OptimizationBase):
|
||||
# A deadline in the past means the target is due right now.
|
||||
return max(deadline_slot, start_slot)
|
||||
|
||||
def _full_charge_slot(self, parameters: GeneticOptimizationParameters) -> Optional[int]:
|
||||
"""Slot by which the battery has to reach its full charge target once.
|
||||
|
||||
Args:
|
||||
parameters: Optimization parameters of this run.
|
||||
|
||||
Returns:
|
||||
Absolute slot index, capped to the end of the control horizon, or None
|
||||
when no full charge is due.
|
||||
"""
|
||||
battery = parameters.pv_battery
|
||||
if battery is None or battery.full_charge_soc_percentage is None:
|
||||
return None
|
||||
|
||||
start_slot = self._control_start_slot()
|
||||
deadline = battery.full_charge_deadline_datetime
|
||||
if deadline is None:
|
||||
return self.control_end_slot
|
||||
if self._slot0_datetime is None:
|
||||
raise ValueError("Full charge deadline requires a run start timestamp.")
|
||||
seconds = (
|
||||
deadline.in_timezone(self._slot0_datetime.timezone) - self._slot0_datetime
|
||||
).total_seconds()
|
||||
deadline_slot = math.floor(seconds / (self.slot_duration_h * 3600) + 1e-9)
|
||||
return min(max(deadline_slot, start_slot), self.control_end_slot)
|
||||
|
||||
def _validate_forecast_availability(self) -> None:
|
||||
"""Use only the contiguous finite forecast prefix after now."""
|
||||
start = self._control_start_slot()
|
||||
@@ -3560,6 +3590,38 @@ class GeneticOptimization(OptimizationBase):
|
||||
except (TypeError, ValueError):
|
||||
return _pack_genes([int(value) for value in relevant])
|
||||
|
||||
def _battery_max_soc_until_full_charge_deadline(
|
||||
self, simulation_result: dict[str, Any], start_slot: int
|
||||
) -> Optional[float]:
|
||||
"""Highest battery SoC reached before the full charge deadline [%].
|
||||
|
||||
The full charge only has to happen once, so the maximum counts, not the
|
||||
SoC at the deadline. ``akku_soc_pro_stunde`` holds the SoC at the start of
|
||||
each slot; the SoC at the start of the deadline slot includes every charge
|
||||
that finished in time. A deadline at the end of the horizon also counts
|
||||
the SoC after the last slot.
|
||||
|
||||
Args:
|
||||
simulation_result: Result of the simulation run for this individual.
|
||||
start_slot: Slot index the result arrays start at.
|
||||
|
||||
Returns:
|
||||
State of charge in percent, or None when no full charge is due.
|
||||
"""
|
||||
deadline_slot = getattr(self, "_full_charge_deadline_slot", None)
|
||||
battery = self.simulation.battery
|
||||
if deadline_slot is None or battery is None:
|
||||
return None
|
||||
soc_per_slot = simulation_result.get("akku_soc_pro_stunde")
|
||||
if soc_per_slot is None:
|
||||
return battery.current_soc_percentage()
|
||||
index = deadline_slot - start_slot
|
||||
reached = [float(value) for value in soc_per_slot[: max(index, 0) + 1]]
|
||||
if index >= len(soc_per_slot):
|
||||
reached.append(battery.current_soc_percentage())
|
||||
reached = [value for value in reached if math.isfinite(value)]
|
||||
return max(reached) if reached else battery.current_soc_percentage()
|
||||
|
||||
def _ev_soc_at_deadline(self, simulation_result: dict[str, Any], start_slot: int) -> float:
|
||||
"""EV state of charge the target is checked against [%].
|
||||
|
||||
@@ -3794,6 +3856,25 @@ class GeneticOptimization(OptimizationBase):
|
||||
excess_cost_per_wh = break_even_price - best_uncovered_price
|
||||
gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor
|
||||
|
||||
# --- Periodic full charge (e.g. LFP recalibration) ---
|
||||
# Only the shortfall of the best SoC reached before the deadline counts,
|
||||
# so the optimizer is free to pick the cheapest slot for it.
|
||||
# Lightweight callers construct the optimizer without __init__ (see
|
||||
# evaluate()); a missing target must behave like no full charge.
|
||||
target = getattr(self, "_full_charge_target_soc", None)
|
||||
if target is not None:
|
||||
reached = self._battery_max_soc_until_full_charge_deadline(
|
||||
simulation_result, start_hour
|
||||
)
|
||||
if reached is not None and reached < target:
|
||||
try:
|
||||
full_charge_penalty = float(
|
||||
self._genetic_cfg.penalties["battery_full_charge_miss"]
|
||||
)
|
||||
except Exception:
|
||||
full_charge_penalty = 10.0
|
||||
gesamtbilanz += (target - reached) * full_charge_penalty
|
||||
|
||||
if self.optimize_ev and parameters.ev and self.simulation.ev:
|
||||
try:
|
||||
penalty = self._genetic_cfg.penalties["ev_soc_miss"]
|
||||
@@ -4242,6 +4323,16 @@ class GeneticOptimization(OptimizationBase):
|
||||
# EV charging deadline (departure). Resolved once per run; the seeding
|
||||
# heuristic and the SoC penalty both read it.
|
||||
self._ev_soc_deadline_slot = self._ev_deadline_slot(parameters)
|
||||
self._full_charge_deadline_slot = self._full_charge_slot(parameters)
|
||||
self._full_charge_target_soc = (
|
||||
parameters.pv_battery.full_charge_soc_percentage
|
||||
if self._full_charge_deadline_slot is not None and parameters.pv_battery
|
||||
else None
|
||||
)
|
||||
if self._full_charge_deadline_slot is not None:
|
||||
logger.debug(
|
||||
"Battery full charge required by slot {}.", self._full_charge_deadline_slot
|
||||
)
|
||||
if self._ev_soc_deadline_slot is not None:
|
||||
logger.debug(
|
||||
"EV target SoC required by slot {} ({}).",
|
||||
|
||||
@@ -213,6 +213,7 @@ class GeneticCommonSettings(SettingsBaseModel):
|
||||
default_factory=lambda: dict[str, float | int | str](
|
||||
ev_soc_miss=10,
|
||||
ac_charge_break_even=1.0,
|
||||
battery_full_charge_miss=10,
|
||||
),
|
||||
json_schema_extra={
|
||||
"description": "Penalty parameters used in fitness evaluation.",
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Periodic full charge of a stationary battery (e.g. LFP recalibration)."""
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.config.config import ConfigEOS
|
||||
from akkudoktoreos.core.cache import CacheEnergyManagementStore
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
|
||||
from akkudoktoreos.optimization.genetic import configrequest
|
||||
from akkudoktoreos.optimization.genetic.configrequest import ConfigOptimizationRequest
|
||||
from akkudoktoreos.optimization.genetic.fullcharge import (
|
||||
FULL_CHARGE_MIN_LEAD_HOURS,
|
||||
enforced_full_charge_deadline,
|
||||
full_charge_deadline,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
ems_eos = get_ems(init=True) # init once
|
||||
|
||||
NOW = to_datetime("2026-11-20T12:00:00+01:00")
|
||||
|
||||
|
||||
def _daily(days: int, value: float, now: DateTime = NOW) -> list[tuple[DateTime, Any]]:
|
||||
"""One SoC sample per day for the last ``days`` days, oldest first."""
|
||||
return [(now.subtract(days=day), value) for day in range(days, 0, -1)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deadline from the measured history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deadline_counts_from_last_full_charge():
|
||||
samples = _daily(21, 0.4) + [(NOW.subtract(days=3), 0.995)]
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == NOW.subtract(days=3).add(days=21)
|
||||
|
||||
|
||||
def test_deadline_uses_the_latest_of_several_full_charges():
|
||||
samples = [(NOW.subtract(days=10), 1.0), (NOW.subtract(days=4), 1.0)] + _daily(21, 0.4)
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == NOW.subtract(days=4).add(days=21)
|
||||
|
||||
|
||||
def test_detect_threshold_decides_what_counts_as_full():
|
||||
samples = _daily(21, 0.4) + [(NOW.subtract(days=2), 0.98)]
|
||||
# 98 % is not full with the default threshold: the whole window is history
|
||||
# without a full charge, so the interval counts from the oldest sample.
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == NOW
|
||||
assert full_charge_deadline(samples, NOW, 21, 98) == NOW.subtract(days=2).add(days=21)
|
||||
|
||||
|
||||
def test_overdue_deadline_stays_in_the_past():
|
||||
"""The real deadline decides whether a full charge is due."""
|
||||
samples = [(NOW.subtract(days=30), 1.0)] + _daily(29, 0.4)
|
||||
# The interval is elapsed time: 21 x 24 h, across the DST change in October.
|
||||
expected = NOW.subtract(days=30) + timedelta(days=21)
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == expected
|
||||
assert expected.timestamp() < NOW.timestamp()
|
||||
|
||||
|
||||
def test_enforced_deadline_leaves_lead_time():
|
||||
lead = NOW.add(hours=FULL_CHARGE_MIN_LEAD_HOURS)
|
||||
assert enforced_full_charge_deadline(NOW.subtract(days=9), NOW) == lead
|
||||
assert enforced_full_charge_deadline(NOW.add(hours=5), NOW) == lead
|
||||
assert enforced_full_charge_deadline(NOW.add(days=3), NOW) == NOW.add(days=3)
|
||||
|
||||
|
||||
def test_short_history_without_full_charge_counts_from_oldest_sample():
|
||||
"""A fresh installation is not forced into a full charge on day one."""
|
||||
samples = _daily(3, 0.4)
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == NOW.subtract(days=3).add(days=21)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, np.nan, np.inf, "1.0"])
|
||||
def test_invalid_and_future_samples_are_ignored(value):
|
||||
samples = [(NOW.subtract(days=2), value), (NOW.add(minutes=5), 1.0)] + _daily(5, 0.4)
|
||||
assert full_charge_deadline(samples, NOW, 21, 99) == NOW.subtract(days=5).add(days=21)
|
||||
|
||||
|
||||
def test_no_history_gives_no_deadline():
|
||||
assert full_charge_deadline([], NOW, 21, 99) is None
|
||||
assert full_charge_deadline([(NOW.subtract(days=1), None)], NOW, 21, 99) is None
|
||||
|
||||
|
||||
def test_fractional_interval():
|
||||
samples = [(NOW.subtract(days=1), 1.0)]
|
||||
assert full_charge_deadline(samples, NOW, 2.5, 99) == NOW.subtract(days=1).add(hours=60)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings and parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_defaults_disable_the_feature():
|
||||
settings = BatteriesCommonSettings(device_id="battery1")
|
||||
assert settings.full_charge_interval_days is None
|
||||
assert settings.cell_chemistry is None
|
||||
assert settings.full_charge_soc_percentage == 100
|
||||
assert settings.full_charge_detect_percentage == 99
|
||||
assert settings.full_charge_allow_grid is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[
|
||||
{"full_charge_interval_days": 0},
|
||||
{"full_charge_interval_days": -1},
|
||||
{"full_charge_soc_percentage": 0},
|
||||
{"full_charge_soc_percentage": 101},
|
||||
{"full_charge_soc_percentage": 95, "full_charge_detect_percentage": 99},
|
||||
{"cell_chemistry": "lithium"},
|
||||
],
|
||||
)
|
||||
def test_invalid_settings_are_rejected(extra):
|
||||
with pytest.raises(ValidationError):
|
||||
BatteriesCommonSettings(device_id="battery1", **extra)
|
||||
|
||||
|
||||
def test_parameters_reject_target_above_max_soc():
|
||||
with pytest.raises(ValidationError):
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=10000,
|
||||
max_soc_percentage=90,
|
||||
full_charge_soc_percentage=100,
|
||||
)
|
||||
|
||||
|
||||
def test_parameters_parse_naive_deadline_as_local_time():
|
||||
parameters = SolarPanelBatteryParameters.model_validate(
|
||||
{
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"full_charge_soc_percentage": 100,
|
||||
"full_charge_deadline_datetime": "2026-11-21T07:00:00",
|
||||
}
|
||||
)
|
||||
assert parameters.full_charge_deadline_datetime == to_datetime("2026-11-21T07:00:00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolving the configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_charge_request(config_eos, monkeypatch):
|
||||
"""A configured battery with a 30 h control horizon from 10:15 UTC."""
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": 48},
|
||||
"optimization": {
|
||||
"algorithm": "GENETIC",
|
||||
"genetic": {
|
||||
"interval_sec": 900,
|
||||
"horizon_hours": 30,
|
||||
"tail_horizon_hours": 0,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
"individuals": 10,
|
||||
"generations": 10,
|
||||
"seed": 42,
|
||||
},
|
||||
},
|
||||
"devices": {
|
||||
"max_batteries": 1,
|
||||
"max_electric_vehicles": 0,
|
||||
"max_inverters": 1,
|
||||
"max_home_appliances": 0,
|
||||
"batteries": {
|
||||
"storage": {
|
||||
"capacity_wh": 10000,
|
||||
"max_charge_power_w": 5000,
|
||||
"max_soc_percentage": 90,
|
||||
"full_charge_interval_days": 21,
|
||||
}
|
||||
},
|
||||
"electric_vehicles": {},
|
||||
"inverters": {"inverter": {"battery_id": "storage", "max_power_w": 5000}},
|
||||
"home_appliances": {},
|
||||
},
|
||||
}
|
||||
)
|
||||
start = to_datetime("2026-09-12T10:15:00Z", in_timezone="UTC")
|
||||
ems = SimpleNamespace(
|
||||
start_datetime=start, observation_datetime=start, genetic_solution=lambda: None
|
||||
)
|
||||
monkeypatch.setattr(configrequest, "get_ems", lambda: ems)
|
||||
measurement = Mock(key_to_lists=AsyncMock(return_value=([], [])))
|
||||
monkeypatch.setattr(ConfigOptimizationRequest, "measurement", measurement)
|
||||
data = {
|
||||
"soc": {"storage": 40},
|
||||
"forecasts": {
|
||||
"pv_forecast_wh": [0.0] * 192,
|
||||
"total_load": [200.0] * 192,
|
||||
"electricity_price_per_wh": [0.0003] * 192,
|
||||
"feed_in_tariff_per_wh": [0.00008] * 192,
|
||||
},
|
||||
}
|
||||
return config_eos, start, measurement, data
|
||||
|
||||
|
||||
def _history(start: DateTime, full_days_ago: Optional[float]) -> tuple[list[DateTime], list[float]]:
|
||||
"""Hourly SoC history over 30 days, full once ``full_days_ago`` (if given)."""
|
||||
dates = [start.subtract(hours=hour) for hour in range(30 * 24, 0, -1)]
|
||||
values = [0.4] * len(dates)
|
||||
if full_days_ago is not None:
|
||||
index = len(dates) - int(full_days_ago * 24)
|
||||
values[index] = 1.0
|
||||
return dates, values
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_disabled_reads_no_history(full_charge_request):
|
||||
config, _, measurement, data = full_charge_request
|
||||
config.devices.batteries["storage"].full_charge_interval_days = None
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 90
|
||||
assert parameters.pv_battery.full_charge_soc_percentage is None
|
||||
measurement.key_to_lists.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_due_within_horizon_leaves_battery_unchanged(full_charge_request):
|
||||
_, start, measurement, data = full_charge_request
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=5)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 90
|
||||
assert parameters.pv_battery.full_charge_soc_percentage is None
|
||||
assert parameters.pv_battery.full_charge_deadline_datetime is None
|
||||
# The history window covers the whole interval.
|
||||
kwargs = measurement.key_to_lists.call_args.kwargs
|
||||
assert kwargs["key"] == "storage-soc-factor"
|
||||
assert kwargs["start_datetime"] == start.subtract(days=21)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_due_without_grid_only_raises_the_maximum(full_charge_request):
|
||||
_, start, measurement, data = full_charge_request
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=20.5)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 100
|
||||
assert parameters.pv_battery.full_charge_soc_percentage is None
|
||||
assert parameters.pv_battery.full_charge_deadline_datetime is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_due_with_grid_enforces_target_and_deadline(full_charge_request):
|
||||
config, start, measurement, data = full_charge_request
|
||||
config.devices.batteries["storage"].full_charge_allow_grid = True
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=None)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 100
|
||||
assert parameters.pv_battery.full_charge_soc_percentage == 100
|
||||
# Overdue: the optimizer gets the minimum lead time.
|
||||
assert parameters.pv_battery.full_charge_deadline_datetime == start.add(
|
||||
hours=FULL_CHARGE_MIN_LEAD_HOURS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overdue_full_charge_is_due_with_a_horizon_shorter_than_the_lead_time(
|
||||
full_charge_request,
|
||||
):
|
||||
"""Regression: the lead time must not push an overdue full charge out of the horizon."""
|
||||
config, start, measurement, data = full_charge_request
|
||||
config.merge_settings_from_dict({"optimization": {"genetic": {"horizon_hours": 12}}})
|
||||
config.devices.batteries["storage"].full_charge_allow_grid = True
|
||||
measurement.key_to_lists.return_value = _history(start, full_days_ago=None)
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 100
|
||||
assert parameters.pv_battery.full_charge_soc_percentage == 100
|
||||
assert parameters.pv_battery.full_charge_deadline_datetime == start.add(
|
||||
hours=FULL_CHARGE_MIN_LEAD_HOURS
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_history_does_not_schedule_a_full_charge(full_charge_request):
|
||||
config, _, measurement, data = full_charge_request
|
||||
config.devices.batteries["storage"].full_charge_allow_grid = True
|
||||
measurement.key_to_lists.side_effect = KeyError("storage-soc-factor")
|
||||
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
|
||||
assert parameters.pv_battery is not None
|
||||
assert parameters.pv_battery.max_soc_percentage == 90
|
||||
assert parameters.pv_battery.full_charge_soc_percentage is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optimizer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _optimizer_config(config_eos: ConfigEOS, hours: int) -> None:
|
||||
config_eos.merge_settings_from_dict(
|
||||
{
|
||||
"prediction": {"hours": hours},
|
||||
"optimization": {
|
||||
"genetic": {
|
||||
"individuals": 100,
|
||||
"generations": 40,
|
||||
"tail_horizon_hours": 0,
|
||||
"horizon_hours": hours,
|
||||
"interval_sec": 3600,
|
||||
"terminal_value_mode": "FIXED",
|
||||
"terminal_value_euro_per_kwh": 0.0,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _full_charge_parameters(hours: int, **battery_extra) -> GeneticOptimizationParameters:
|
||||
"""No PV and expensive energy: without a target nothing would be charged."""
|
||||
return GeneticOptimizationParameters.model_validate(
|
||||
dict(
|
||||
ems={
|
||||
"pv_prognose_wh": [0.0] * hours,
|
||||
"strompreis_euro_pro_wh": [0.0004] * hours,
|
||||
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||
"preis_euro_pro_wh_akku": 0.0,
|
||||
"gesamtlast": [200.0] * hours,
|
||||
},
|
||||
pv_battery={
|
||||
"device_id": "battery1",
|
||||
"capacity_wh": 10000,
|
||||
"initial_soc_percentage": 30,
|
||||
"min_soc_percentage": 0,
|
||||
"max_soc_percentage": 100,
|
||||
"charging_efficiency": 1.0,
|
||||
"discharging_efficiency": 1.0,
|
||||
"max_charge_power_w": 5000,
|
||||
**battery_extra,
|
||||
},
|
||||
inverter={
|
||||
"device_id": "inverter1",
|
||||
"max_power_wh": 10000,
|
||||
"battery_id": "battery1",
|
||||
"ac_to_dc_efficiency": 1.0,
|
||||
"dc_to_ac_efficiency": 1.0,
|
||||
"max_ac_charge_power_w": 5000,
|
||||
},
|
||||
ev=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_full_charge_slot_resolution(config_eos: ConfigEOS):
|
||||
_optimizer_config(config_eos, 24)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
optimization._slot0_datetime = optimization.ems.start_datetime
|
||||
slot0 = optimization._slot0_datetime
|
||||
|
||||
assert optimization._full_charge_slot(_full_charge_parameters(24)) is None
|
||||
parameters = _full_charge_parameters(24, full_charge_soc_percentage=100)
|
||||
assert optimization._full_charge_slot(parameters) == optimization.control_end_slot
|
||||
parameters = _full_charge_parameters(
|
||||
24, full_charge_soc_percentage=100, full_charge_deadline_datetime=slot0.add(hours=10)
|
||||
)
|
||||
assert optimization._full_charge_slot(parameters) == 10
|
||||
parameters = _full_charge_parameters(
|
||||
24, full_charge_soc_percentage=100, full_charge_deadline_datetime=slot0.add(hours=100)
|
||||
)
|
||||
assert optimization._full_charge_slot(parameters) == optimization.control_end_slot
|
||||
|
||||
|
||||
def test_maximum_soc_before_deadline_counts(config_eos: ConfigEOS):
|
||||
_optimizer_config(config_eos, 24)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
optimization = GeneticOptimization(fixed_seed=1)
|
||||
optimization.simulation.battery = MagicMock(spec=Battery)
|
||||
optimization.simulation.battery.current_soc_percentage.return_value = 40.0
|
||||
result = {"akku_soc_pro_stunde": [30.0, 100.0, 60.0, 50.0]}
|
||||
|
||||
optimization._full_charge_deadline_slot = None
|
||||
assert optimization._battery_max_soc_until_full_charge_deadline(result, 10) is None
|
||||
|
||||
# Slot 11 starts after the charge in slot 10 finished.
|
||||
optimization._full_charge_deadline_slot = 11
|
||||
assert optimization._battery_max_soc_until_full_charge_deadline(result, 10) == 100.0
|
||||
optimization._full_charge_deadline_slot = 10
|
||||
assert optimization._battery_max_soc_until_full_charge_deadline(result, 10) == 30.0
|
||||
|
||||
# Deadline at the end also counts the SoC after the last slot.
|
||||
result = {"akku_soc_pro_stunde": [30.0, 20.0]}
|
||||
optimization.simulation.battery.current_soc_percentage.return_value = 95.0
|
||||
optimization._full_charge_deadline_slot = 99
|
||||
assert optimization._battery_max_soc_until_full_charge_deadline(result, 10) == 95.0
|
||||
|
||||
|
||||
def _run(config_eos: ConfigEOS, **battery_extra) -> list[float]:
|
||||
hours = 24
|
||||
_optimizer_config(config_eos, hours)
|
||||
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||
CacheEnergyManagementStore().clear()
|
||||
solution = GeneticOptimization(fixed_seed=42).optimize_ems(
|
||||
parameters=_full_charge_parameters(hours, **battery_extra), start_hour=0, ngen=40
|
||||
)
|
||||
return list(solution.result.battery_soc_per_hour)
|
||||
|
||||
|
||||
def test_due_full_charge_is_reached_before_deadline(config_eos: ConfigEOS):
|
||||
without = _run(config_eos)
|
||||
assert max(without) < 99.0
|
||||
|
||||
slot0 = to_datetime().set(hour=0, minute=0, second=0, microsecond=0)
|
||||
soc = _run(
|
||||
config_eos,
|
||||
full_charge_soc_percentage=100,
|
||||
full_charge_deadline_datetime=slot0.add(hours=12),
|
||||
)
|
||||
# Slot 12 starts at the deadline; its start-of-slot SoC includes every
|
||||
# charge that finished in time.
|
||||
assert max(soc[:13]) >= 99.0
|
||||
Reference in New Issue
Block a user