mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
feat(optimization): deadlines for consumers and EV, graded grid export
Three related scheduling improvements, all opt-in and behaviour-preserving
when the new fields are not set.
Flexible consumers get absolute time bounds next to the recurring
time_windows: earliest_start_datetime and deadline_datetime, where the
deadline requires the complete run to have *finished* before that moment
("clean dishes by 03:00 tonight"). When no start can meet it,
deadline_policy decides between BEST_EFFORT (run as early as possible, so
the delay rather than the cost is minimized) and STRICT (keep the
deadline; a ONCE consumer then fails the optimization). The solution
reports appliance_deadline_missed per device.
The EV charging target can be given the same kind of deadline, as an
absolute min_soc_deadline_datetime and/or a relative min_soc_max_duration_h
("full in 6 hours"), the earlier of the two winning. The ev_soc_miss
penalty is then evaluated at that slot instead of at the end of the
horizon, and the seeding heuristic only proposes charge slots before it.
Battery-to-grid export under direct marketing is no longer all-or-nothing:
grid_export_rates configures the selectable export levels as a factor of
the rated discharge power (default [0.25, 0.5, 0.75, 1.0]). Each rate is
its own optimizer state, with the full-power state keeping its previous
index so existing seeds and heuristics are unaffected. The chosen level
per slot is reported in battery_grid_export_factor and as the
GRID_SUPPORT_EXPORT operation factor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
weekday/date restrictions) and the horizon; ONCE without any valid start is rejected.
|
weekday/date restrictions) and the horizon; ONCE without any valid start is rejected.
|
||||||
Results are reported per device (`result.home_appliance_energy_wh`, `appliance_starts`,
|
Results are reported per device (`result.home_appliance_energy_wh`, `appliance_starts`,
|
||||||
per-device solution columns and `DDBCInstruction`s emitted only on RUN/OFF transitions).
|
per-device solution columns and `DDBCInstruction`s emitted only on RUN/OFF transitions).
|
||||||
|
- Flexible consumers can be given absolute time bounds: `earliest_start_datetime` and
|
||||||
|
`deadline_datetime`, where the deadline demands that the complete run has *finished* before
|
||||||
|
that moment ("clean dishes by 03:00 tonight"). When no start can meet the deadline,
|
||||||
|
`deadline_policy` decides between `BEST_EFFORT` (run as early as possible, minimizing the
|
||||||
|
delay) and `STRICT` (keep the deadline; a `ONCE` consumer then fails the optimization).
|
||||||
|
The solution reports `appliance_deadline_missed` per device so callers can warn instead of
|
||||||
|
silently trusting a late schedule.
|
||||||
|
- Battery-to-grid export (direct marketing) is no longer all-or-nothing: `grid_export_rates`
|
||||||
|
configures the selectable export levels as a factor of the rated discharge power
|
||||||
|
(default `[0.25, 0.5, 0.75, 1.0]`), settable per battery in `devices.batteries[].
|
||||||
|
grid_export_rates` or per request in `pv_akku.grid_export_rates`. The optimizer picks one
|
||||||
|
level per slot and reports it in `battery_grid_export_factor` and as the
|
||||||
|
`GRID_SUPPORT_EXPORT` operation factor. `[1.0]` restores the previous behaviour.
|
||||||
|
- The EV charging target can be given a deadline: `min_soc_deadline_datetime` (absolute, e.g.
|
||||||
|
the next departure) and/or `min_soc_max_duration_h` ("full in 6 hours"), the earlier of the
|
||||||
|
two applies. The `ev_soc_miss` penalty is then evaluated at that slot instead of at the end
|
||||||
|
of the horizon, and the seeding heuristics only propose charge slots before it. Without a
|
||||||
|
deadline the behaviour is unchanged.
|
||||||
- EV Bug (wrong output in genetic.py / no senseful results)
|
- EV Bug (wrong output in genetic.py / no senseful results)
|
||||||
- Direktvermarktung active / Battery discharge into grid (new state / action battery_grid_export_allowed) + (new simulation output Feed_in_tariff)
|
- Direktvermarktung active / Battery discharge into grid (new state / action battery_grid_export_allowed) + (new simulation output Feed_in_tariff)
|
||||||
- New PV forecast providers giving operators more cloud forecast sources to choose from in
|
- New PV forecast providers giving operators more cloud forecast sources to choose from in
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100
|
"max_soc_percentage": 100
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100
|
"max_soc_percentage": 100
|
||||||
}
|
}
|
||||||
@@ -120,6 +132,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100,
|
"max_soc_percentage": 100,
|
||||||
"measurement_key_soc_factor": "battery1-soc-factor",
|
"measurement_key_soc_factor": "battery1-soc-factor",
|
||||||
@@ -159,6 +177,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100,
|
"max_soc_percentage": 100,
|
||||||
"measurement_key_soc_factor": "battery1-soc-factor",
|
"measurement_key_soc_factor": "battery1-soc-factor",
|
||||||
@@ -470,6 +494,7 @@ of the two must be provided.
|
|||||||
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
|
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
|
||||||
| device_id | `str` | `rw` | `<unknown>` | ID of device |
|
| device_id | `str` | `rw` | `<unknown>` | ID of device |
|
||||||
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
|
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
|
||||||
|
| grid_export_rates | `Optional[list[float]]` | `rw` | `[0.25, 0.5, 0.75, 1.0]` | Battery-to-grid export rates as factor of maximum discharge power ]0.00 ... 1.00]. Only used with direct marketing (feedintariff.direct_marketing_enabled). Each rate is one additional optimizer state; [1.0] restores all-or-nothing export. None triggers fallback to default export-rates. |
|
||||||
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), applied once to each kWh delivered by the battery [€/kWh]. |
|
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), applied once to each kWh delivered by the battery [€/kWh]. |
|
||||||
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
|
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
|
||||||
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
|
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
|
||||||
@@ -508,6 +533,12 @@ of the two must be provided.
|
|||||||
0.75,
|
0.75,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 10,
|
"min_soc_percentage": 10,
|
||||||
"max_soc_percentage": 100
|
"max_soc_percentage": 100
|
||||||
}
|
}
|
||||||
@@ -541,6 +572,12 @@ of the two must be provided.
|
|||||||
0.75,
|
0.75,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 10,
|
"min_soc_percentage": 10,
|
||||||
"max_soc_percentage": 100,
|
"max_soc_percentage": 100,
|
||||||
"measurement_key_soc_factor": "battery1-soc-factor",
|
"measurement_key_soc_factor": "battery1-soc-factor",
|
||||||
|
|||||||
@@ -58,6 +58,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100
|
"max_soc_percentage": 100
|
||||||
}
|
}
|
||||||
@@ -85,6 +91,12 @@
|
|||||||
0.9,
|
0.9,
|
||||||
1.0
|
1.0
|
||||||
],
|
],
|
||||||
|
"grid_export_rates": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
"min_soc_percentage": 0,
|
"min_soc_percentage": 0,
|
||||||
"max_soc_percentage": 100
|
"max_soc_percentage": 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Akkudoktor-EOS
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
**Version**: `v0.3.0.dev2608010951962828`
|
**Version**: `v0.3.0.dev2609031505836006`
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- pyml disable line-length -->
|
||||||
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ to `DISABLED` in the configuration.
|
|||||||
"levelized_cost_of_storage_kwh": 0.12,
|
"levelized_cost_of_storage_kwh": 0.12,
|
||||||
"max_charge_power_w": 5000,
|
"max_charge_power_w": 5000,
|
||||||
"initial_soc_percentage": 80,
|
"initial_soc_percentage": 80,
|
||||||
"min_soc_percentage": 15
|
"min_soc_percentage": 15,
|
||||||
|
"grid_export_rates": [0.25, 0.5, 0.75, 1.0]
|
||||||
},
|
},
|
||||||
"inverter": {
|
"inverter": {
|
||||||
"device_id": "inverter1",
|
"device_id": "inverter1",
|
||||||
@@ -91,7 +92,9 @@ to `DISABLED` in the configuration.
|
|||||||
"discharging_efficiency": 1.0,
|
"discharging_efficiency": 1.0,
|
||||||
"max_charge_power_w": 11040,
|
"max_charge_power_w": 11040,
|
||||||
"initial_soc_percentage": 54,
|
"initial_soc_percentage": 54,
|
||||||
"min_soc_percentage": 0
|
"min_soc_percentage": 0,
|
||||||
|
"min_soc_deadline_datetime": null,
|
||||||
|
"min_soc_max_duration_h": null
|
||||||
},
|
},
|
||||||
"home_appliances": [
|
"home_appliances": [
|
||||||
{
|
{
|
||||||
@@ -99,7 +102,10 @@ to `DISABLED` in the configuration.
|
|||||||
"consumption_wh": 2000,
|
"consumption_wh": 2000,
|
||||||
"duration_h": 3,
|
"duration_h": 3,
|
||||||
"schedule_mode": "ONCE",
|
"schedule_mode": "ONCE",
|
||||||
"time_windows": null
|
"time_windows": null,
|
||||||
|
"earliest_start_datetime": null,
|
||||||
|
"deadline_datetime": "2026-07-16T03:00:00+02:00",
|
||||||
|
"deadline_policy": "BEST_EFFORT"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"temperature_forecast": [
|
"temperature_forecast": [
|
||||||
@@ -217,6 +223,32 @@ Verify prices against your local tariffs.
|
|||||||
- `levelized_cost_of_storage_kwh`: LCOS in EUR/kWh, charged once for every kWh of DC energy
|
- `levelized_cost_of_storage_kwh`: LCOS in EUR/kWh, charged once for every kWh of DC energy
|
||||||
delivered by the battery. Default: `0.0`.
|
delivered by the battery. Default: `0.0`.
|
||||||
- `max_charge_power_w`: Maximum charging power in W
|
- `max_charge_power_w`: Maximum charging power in W
|
||||||
|
- `charge_rates`: Selectable AC charge levels as factor of `max_charge_power_w`.
|
||||||
|
Defaults to the configured `devices.batteries[0].charge_rates`.
|
||||||
|
- `grid_export_rates`: Selectable battery-to-grid export levels, see below.
|
||||||
|
Defaults to the configured `devices.batteries[0].grid_export_rates`.
|
||||||
|
|
||||||
|
#### Battery Grid Export Levels (`grid_export_rates`)
|
||||||
|
|
||||||
|
With direct marketing enabled (`feedintariff.direct_marketing_enabled`) the battery may discharge
|
||||||
|
into the grid. The export is not all-or-nothing: `grid_export_rates` lists the selectable export
|
||||||
|
levels as a factor of the battery's rated discharge power, for example
|
||||||
|
`[0.25, 0.5, 0.75, 1.0]` (the default). The optimizer picks one level per slot, so it can spread a
|
||||||
|
limited amount of stored energy over several expensive slots instead of emptying the battery into
|
||||||
|
the first one.
|
||||||
|
|
||||||
|
Each rate is one more state in the genetic state space, which is why the default is deliberately
|
||||||
|
coarse. `[1.0]` restores the previous all-or-nothing behaviour.
|
||||||
|
|
||||||
|
The exported energy of one slot is bounded by
|
||||||
|
|
||||||
|
```{math}
|
||||||
|
E_{export} \le \min\bigl(P_{inv,free}\,\Delta t,\; E_{bat,remaining},\; r\,P_{bat,rated}\,\Delta t\bigr)
|
||||||
|
```
|
||||||
|
|
||||||
|
where `r` is the selected rate. The rate applies to the *rated* discharge power, so it stays a plain
|
||||||
|
power setpoint: local self-consumption earlier in the same slot lowers `E_bat,remaining`, but it does
|
||||||
|
not silently raise the export level.
|
||||||
|
|
||||||
#### Battery LCOS (`levelized_cost_of_storage_kwh`)
|
#### Battery LCOS (`levelized_cost_of_storage_kwh`)
|
||||||
|
|
||||||
@@ -314,8 +346,66 @@ smaller values (e.g. `0.0`) disable the penalty entirely.
|
|||||||
- `discharging_efficiency`: Discharging efficiency (0-1)
|
- `discharging_efficiency`: Discharging efficiency (0-1)
|
||||||
- `max_charge_power_w`: Maximum charging power in W
|
- `max_charge_power_w`: Maximum charging power in W
|
||||||
- `initial_soc_percentage`: Current charge level (%)
|
- `initial_soc_percentage`: Current charge level (%)
|
||||||
- `min_soc_percentage`: Minimum allowed SoC (%)
|
- `min_soc_percentage`: Charging target; minimum allowed SoC (%)
|
||||||
- `max_soc_percentage`: Maximum allowed SoC (%)
|
- `max_soc_percentage`: Maximum allowed SoC (%)
|
||||||
|
- `min_soc_deadline_datetime`: Absolute moment by which `min_soc_percentage` has to be reached
|
||||||
|
- `min_soc_max_duration_h`: Maximum time from the start of the optimization until
|
||||||
|
`min_soc_percentage` has to be reached (h)
|
||||||
|
|
||||||
|
#### Charging Deadline
|
||||||
|
|
||||||
|
By default `min_soc_percentage` only has to be reached by the end of the optimization horizon, so
|
||||||
|
the optimizer is free to charge in the cheapest slots anywhere in the horizon. A deadline moves
|
||||||
|
that requirement forward - typically to the next departure:
|
||||||
|
|
||||||
|
- `min_soc_deadline_datetime`: an absolute instant (`2026-07-16T07:00:00+02:00`). A value without
|
||||||
|
timezone is read as local time.
|
||||||
|
- `min_soc_max_duration_h`: the same thing relative to the start of the optimization
|
||||||
|
("full in 6 hours"), which avoids timestamp arithmetic in the calling automation.
|
||||||
|
|
||||||
|
Both may be given; the earlier one applies. A deadline beyond the horizon is ignored, a deadline in
|
||||||
|
the past means the target is due immediately. The SoC-miss penalty
|
||||||
|
(`optimization.genetic.penalties.ev_soc_miss`) is then evaluated at the deadline instead of at the
|
||||||
|
end of the horizon, and the seeding heuristics only propose charge slots before it. Charging after
|
||||||
|
the deadline is not forbidden - it simply no longer helps to avoid the penalty.
|
||||||
|
|
||||||
|
The deadline is a target, not a hard constraint: if the remaining time is too short to reach
|
||||||
|
`min_soc_percentage`, the optimizer charges as much as it can and accepts the penalty. Check
|
||||||
|
`result.EAuto_SoC_pro_Stunde` at the deadline slot to see what was actually achieved.
|
||||||
|
|
||||||
|
### Flexible Consumers (Home Appliances)
|
||||||
|
|
||||||
|
Each entry of `home_appliances` describes one consumer whose run the optimizer may place in time.
|
||||||
|
The load of a single complete run is defined **either** by an explicit profile
|
||||||
|
(`load_profile_power_w` with `load_profile_interval_seconds`) **or** by the flat fallback
|
||||||
|
`consumption_wh` + `duration_h`.
|
||||||
|
|
||||||
|
- `device_id`: Unique ID of the consumer, used in all result columns
|
||||||
|
- `schedule_mode`: `ONCE` (a single run within the horizon) or `DAILY` (one run per local calendar
|
||||||
|
day that still has a feasible full run)
|
||||||
|
|
||||||
|
Three independent constraints decide *when* a run may happen; all of them have to hold at once:
|
||||||
|
|
||||||
|
- `time_windows`: recurring wall-clock windows, e.g. "only between 10:00 and 13:00", optionally
|
||||||
|
restricted to a weekday or a date. See {doc}`configtimewindow`.
|
||||||
|
- `earliest_start_datetime`: absolute lower bound. The run may not start before this moment.
|
||||||
|
- `deadline_datetime`: absolute upper bound. The complete run must have **finished** at or before
|
||||||
|
this moment - with a 3 h program and a deadline of 03:00, the last allowed start is 00:00.
|
||||||
|
|
||||||
|
Both datetimes are absolute instants and never roll over into the next day. A value without
|
||||||
|
timezone is read as local time; sending an ISO-8601 timestamp with offset
|
||||||
|
(`2026-07-16T03:00:00+02:00`) is unambiguous.
|
||||||
|
|
||||||
|
#### Missed Deadlines (`deadline_policy`)
|
||||||
|
|
||||||
|
Depending on the current time, the run duration, the horizon and the time windows, a deadline can
|
||||||
|
be unreachable. `deadline_policy` decides what happens then:
|
||||||
|
|
||||||
|
- `BEST_EFFORT` (default): the run is scheduled as early as the remaining constraints allow -
|
||||||
|
minimize the delay instead of the cost ("it should have been done by 03:00, so start now").
|
||||||
|
A warning is logged and `appliance_deadline_missed` reports the miss.
|
||||||
|
- `STRICT`: the deadline is kept. A `ONCE` consumer without a feasible start makes the optimization
|
||||||
|
fail; a `DAILY` consumer is simply not scheduled on days without one.
|
||||||
|
|
||||||
### Temperature Forecast
|
### Temperature Forecast
|
||||||
|
|
||||||
@@ -334,6 +424,7 @@ smaller values (e.g. `0.0`) disable the penalty entirely.
|
|||||||
"dc_charge": [1, 1, ..., 1, 1],
|
"dc_charge": [1, 1, ..., 1, 1],
|
||||||
"discharge_allowed": [0, 0, 1, ..., 0, 0],
|
"discharge_allowed": [0, 0, 1, ..., 0, 0],
|
||||||
"battery_grid_export_allowed": [0, 0, 0, ..., 1, 0],
|
"battery_grid_export_allowed": [0, 0, 0, ..., 1, 0],
|
||||||
|
"battery_grid_export_factor": [0.0, 0.0, 0.0, ..., 0.5, 0.0],
|
||||||
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
|
"eautocharge_hours_float": [0.625, 0, ..., 0.75, 0],
|
||||||
"result": {
|
"result": {
|
||||||
"Last_Wh_pro_Stunde": [...],
|
"Last_Wh_pro_Stunde": [...],
|
||||||
@@ -356,6 +447,9 @@ smaller values (e.g. `0.0`) disable the penalty entirely.
|
|||||||
- `dc_charge`: DC charging schedule (0-1)
|
- `dc_charge`: DC charging schedule (0-1)
|
||||||
- `discharge_allowed`: Battery discharge permission for local self-consumption/load coverage (0 or 1)
|
- `discharge_allowed`: Battery discharge permission for local self-consumption/load coverage (0 or 1)
|
||||||
- `battery_grid_export_allowed`: Battery discharge permission for grid export/direct marketing (0 or 1)
|
- `battery_grid_export_allowed`: Battery discharge permission for grid export/direct marketing (0 or 1)
|
||||||
|
- `battery_grid_export_factor`: Export level per slot as factor of the rated discharge power
|
||||||
|
(`0.0` where no export is planned). Empty when direct marketing is disabled. A solution without
|
||||||
|
this array exports at full power wherever `battery_grid_export_allowed` is 1.
|
||||||
|
|
||||||
With direct marketing enabled, `dc_charge = 1` and `discharge_allowed = 1` may occur together. This
|
With direct marketing enabled, `dc_charge = 1` and `discharge_allowed = 1` may occur together. This
|
||||||
is the normal self-consumption mode: within a coarse optimization slot, the battery may cover
|
is the normal self-consumption mode: within a coarse optimization slot, the battery may cover
|
||||||
@@ -372,6 +466,13 @@ power.
|
|||||||
|
|
||||||
- `eautocharge_hours_float`: EV charging schedule (0.0-1.0)
|
- `eautocharge_hours_float`: EV charging schedule (0.0-1.0)
|
||||||
|
|
||||||
|
#### Flexible Consumers
|
||||||
|
|
||||||
|
- `appliance_starts`: Scheduled run start times per `device_id` as absolute local datetimes
|
||||||
|
- `appliance_deadline_missed`: Per `device_id` with a `deadline_datetime`, whether the scheduled run
|
||||||
|
misses that deadline (or was not scheduled at all). Consumers without a deadline are not listed.
|
||||||
|
- `result.home_appliance_energy_wh`: Per-device load curve of the scheduled runs in Wh
|
||||||
|
|
||||||
#### Results
|
#### Results
|
||||||
|
|
||||||
The `result` object contains detailed information about the optimization outcome. The length of the
|
The `result` object contains detailed information about the optimization outcome. The length of the
|
||||||
|
|||||||
+225
-3
@@ -8,7 +8,7 @@
|
|||||||
"name": "Apache 2.0",
|
"name": "Apache 2.0",
|
||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
},
|
},
|
||||||
"version": "v0.3.0.dev2608010951962828"
|
"version": "v0.3.0.dev2609031505836006"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/v1/admin/cache/clear": {
|
"/v1/admin/cache/clear": {
|
||||||
@@ -2313,6 +2313,39 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"grid_export_rates": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Grid Export Rates",
|
||||||
|
"description": "Battery-to-grid export rates as factor of maximum discharge power ]0.00 ... 1.00]. Only used with direct marketing (feedintariff.direct_marketing_enabled). Each rate is one additional optimizer state; [1.0] restores all-or-nothing export. None triggers fallback to default export-rates.",
|
||||||
|
"default": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"examples": [
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
"min_soc_percentage": {
|
"min_soc_percentage": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"maximum": 100.0,
|
"maximum": 100.0,
|
||||||
@@ -2468,6 +2501,39 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"grid_export_rates": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Grid Export Rates",
|
||||||
|
"description": "Battery-to-grid export rates as factor of maximum discharge power ]0.00 ... 1.00]. Only used with direct marketing (feedintariff.direct_marketing_enabled). Each rate is one additional optimizer state; [1.0] restores all-or-nothing export. None triggers fallback to default export-rates.",
|
||||||
|
"default": [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"examples": [
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
"min_soc_percentage": {
|
"min_soc_percentage": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"maximum": 100.0,
|
"maximum": 100.0,
|
||||||
@@ -2663,6 +2729,15 @@
|
|||||||
"title": "ConfigSaveMode",
|
"title": "ConfigSaveMode",
|
||||||
"description": "Configuration file save mode."
|
"description": "Configuration file save mode."
|
||||||
},
|
},
|
||||||
|
"ConsumerDeadlinePolicy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"BEST_EFFORT",
|
||||||
|
"STRICT"
|
||||||
|
],
|
||||||
|
"title": "ConsumerDeadlinePolicy",
|
||||||
|
"description": "Behaviour when a flexible consumer's deadline cannot be met.\n\nA deadline (``deadline_datetime``) demands that a complete run has *finished*\nbefore that moment. Depending on \"now\", the run duration, the optimization\nhorizon and the allowed time windows, no such start may exist.\n\nPolicies\n--------\n- BEST_EFFORT:\n Run as early as the remaining constraints allow, i.e. minimize the\n delay instead of the cost (\"it should have been done by 03:00, so\n start now\"). A warning is logged. This keeps an optimization request\n answerable instead of failing it - the usual choice for home\n automation.\n\n- STRICT:\n Keep the deadline. A ONCE consumer without a feasible start makes the\n optimization fail; a DAILY consumer is simply not scheduled on days\n without a feasible start."
|
||||||
|
},
|
||||||
"ConsumerScheduleMode": {
|
"ConsumerScheduleMode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -3914,6 +3989,67 @@
|
|||||||
],
|
],
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"grid_export_rates": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Grid Export Rates",
|
||||||
|
"description": "Battery-to-grid export rates as factor of maximum discharge power ]0.00 ... 1.00]. Only used with direct marketing. None falls back to the configured devices.batteries[0].grid_export_rates.",
|
||||||
|
"examples": [
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"min_soc_deadline_datetime": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Min Soc Deadline Datetime",
|
||||||
|
"description": "Absolute moment by which 'min_soc_percentage' has to be reached (departure time). A date time without timezone is read as local time. None means end of the optimization horizon.",
|
||||||
|
"examples": [
|
||||||
|
null,
|
||||||
|
"2026-07-16T07:00:00+02:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"min_soc_max_duration_h": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number",
|
||||||
|
"exclusiveMinimum": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Min Soc Max Duration H",
|
||||||
|
"description": "Maximum time from the start of the optimization until 'min_soc_percentage' has to be reached [h]. Combined with 'min_soc_deadline_datetime' the earlier of the two applies.",
|
||||||
|
"examples": [
|
||||||
|
null,
|
||||||
|
6.0
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -3923,7 +4059,7 @@
|
|||||||
"capacity_wh"
|
"capacity_wh"
|
||||||
],
|
],
|
||||||
"title": "ElectricVehicleParameters",
|
"title": "ElectricVehicleParameters",
|
||||||
"description": "Battery Electric Vehicle Device Simulation Configuration."
|
"description": "Battery Electric Vehicle Device Simulation Configuration.\n\n``min_soc_percentage`` is the charging target. By default it only has to be\nreached by the end of the optimization horizon; a deadline\n(``min_soc_deadline_datetime`` and/or ``min_soc_max_duration_h``) moves that\nrequirement forward, for example to the next departure."
|
||||||
},
|
},
|
||||||
"ElectricVehicleResult": {
|
"ElectricVehicleResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -5434,6 +5570,14 @@
|
|||||||
"title": "Battery Grid Export Allowed",
|
"title": "Battery Grid Export Allowed",
|
||||||
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
|
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
|
||||||
},
|
},
|
||||||
|
"battery_grid_export_factor": {
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Battery Grid Export Factor",
|
||||||
|
"description": "Array with the battery-to-grid export level per slot as factor of the rated discharge power (0.0 for no export). Empty when direct marketing is disabled; a solution without this array exports at full power wherever 'battery_grid_export_allowed' is 1."
|
||||||
|
},
|
||||||
"eautocharge_hours_float": {
|
"eautocharge_hours_float": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -5500,6 +5644,14 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "Appliance Starts",
|
"title": "Appliance Starts",
|
||||||
"description": "Scheduled run start times per appliance device_id as absolute local datetimes."
|
"description": "Scheduled run start times per appliance device_id as absolute local datetimes."
|
||||||
|
},
|
||||||
|
"appliance_deadline_missed": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "Appliance Deadline Missed",
|
||||||
|
"description": "Per appliance device_id with a 'deadline_datetime': whether the scheduled run misses that deadline (or was not scheduled at all). Appliances without a deadline are not listed."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -5936,6 +6088,49 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"earliest_start_datetime": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Earliest Start Datetime",
|
||||||
|
"description": "Absolute earliest moment the run may start. Starts before it are dropped, in addition to 'time_windows' and the horizon. A date time without timezone is read as local time. This bound is never relaxed.",
|
||||||
|
"examples": [
|
||||||
|
null,
|
||||||
|
"2026-07-15T20:00:00+02:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"deadline_datetime": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Deadline Datetime",
|
||||||
|
"description": "Absolute deadline: the complete run must have *finished* at or before this moment (e.g. end of the day, or 03:00 tonight). A date time without timezone is read as local time. See 'deadline_policy' for what happens when no start can meet it.",
|
||||||
|
"examples": [
|
||||||
|
null,
|
||||||
|
"2026-07-16T03:00:00+02:00"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"deadline_policy": {
|
||||||
|
"$ref": "#/components/schemas/ConsumerDeadlinePolicy",
|
||||||
|
"description": "What to do when 'deadline_datetime' cannot be met: BEST_EFFORT runs as early as possible instead (warning logged), STRICT keeps the deadline (a ONCE consumer then fails the optimization).",
|
||||||
|
"default": "BEST_EFFORT",
|
||||||
|
"examples": [
|
||||||
|
"BEST_EFFORT",
|
||||||
|
"STRICT"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -5944,7 +6139,7 @@
|
|||||||
"device_id"
|
"device_id"
|
||||||
],
|
],
|
||||||
"title": "HomeApplianceParameters",
|
"title": "HomeApplianceParameters",
|
||||||
"description": "Flexible consumer (home appliance) device simulation configuration.\n\nA consumer's load is defined **either** by an explicit power profile\n(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)\n**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one\nof the two must be provided."
|
"description": "Flexible consumer (home appliance) device simulation configuration.\n\nA consumer's load is defined **either** by an explicit power profile\n(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)\n**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one\nof the two must be provided.\n\n*When* the run may happen is constrained by three independent mechanisms that\nall have to hold at once:\n\n- ``time_windows``: recurring wall-clock windows (\"only between 10:00 and 13:00\").\n- ``earliest_start_datetime``: absolute lower bound (\"not before I get home\").\n- ``deadline_datetime``: absolute upper bound; the run must be *finished*\n before that moment (\"clean dishes by 03:00 tonight\")."
|
||||||
},
|
},
|
||||||
"HomeAssistantAdapterCommonSettings-Input": {
|
"HomeAssistantAdapterCommonSettings-Input": {
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9370,6 +9565,33 @@
|
|||||||
null
|
null
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"grid_export_rates": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Grid Export Rates",
|
||||||
|
"description": "Battery-to-grid export rates as factor of maximum discharge power ]0.00 ... 1.00]. Only used with direct marketing. None falls back to the configured devices.batteries[0].grid_export_rates.",
|
||||||
|
"examples": [
|
||||||
|
[
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
"levelized_cost_of_storage_kwh": {
|
"levelized_cost_of_storage_kwh": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 0.0,
|
"minimum": 0.0,
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
|||||||
# Default charge rates for battery
|
# Default charge rates for battery
|
||||||
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||||
|
|
||||||
|
# Default grid export rates for battery (direct marketing). Coarser than the
|
||||||
|
# charge rates on purpose: every rate is one more state the genetic optimizer
|
||||||
|
# has to explore, and export levels below a quarter of the rated power rarely
|
||||||
|
# pay for the extra search effort.
|
||||||
|
BATTERY_DEFAULT_GRID_EXPORT_RATES: list[float] = [0.25, 0.5, 0.75, 1.0]
|
||||||
|
|
||||||
|
|
||||||
class BatteriesCommonSettings(DevicesBaseSettings):
|
class BatteriesCommonSettings(DevicesBaseSettings):
|
||||||
"""Battery devices base settings."""
|
"""Battery devices base settings."""
|
||||||
@@ -87,6 +93,20 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
grid_export_rates: Optional[list[float]] = Field(
|
||||||
|
default=BATTERY_DEFAULT_GRID_EXPORT_RATES,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Battery-to-grid export rates as factor of maximum discharge "
|
||||||
|
"power ]0.00 ... 1.00]. Only used with direct marketing "
|
||||||
|
"(feedintariff.direct_marketing_enabled). Each rate is one "
|
||||||
|
"additional optimizer state; [1.0] restores all-or-nothing "
|
||||||
|
"export. None triggers fallback to default export-rates."
|
||||||
|
),
|
||||||
|
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
min_soc_percentage: int = Field(
|
min_soc_percentage: int = Field(
|
||||||
default=0,
|
default=0,
|
||||||
ge=0,
|
ge=0,
|
||||||
@@ -140,6 +160,32 @@ class BatteriesCommonSettings(DevicesBaseSettings):
|
|||||||
|
|
||||||
return arr
|
return arr
|
||||||
|
|
||||||
|
@field_validator("grid_export_rates", mode="before")
|
||||||
|
def validate_and_sort_grid_export_rates(cls, v: Any) -> NDArray[Shape["*"], float]:
|
||||||
|
"""Normalize the export rates to a sorted, duplicate-free array in ]0, 1]."""
|
||||||
|
# None means fallback to default values
|
||||||
|
if v is None:
|
||||||
|
return BATTERY_DEFAULT_GRID_EXPORT_RATES.copy()
|
||||||
|
|
||||||
|
if isinstance(v, str):
|
||||||
|
numbers = re.split(r"[,\s]+", v.strip("[]"))
|
||||||
|
arr = np.array([float(x) for x in numbers if x])
|
||||||
|
else:
|
||||||
|
arr = np.array(v, dtype=float)
|
||||||
|
|
||||||
|
if arr.size == 0:
|
||||||
|
raise ValueError("grid_export_rates must contain at least one value.")
|
||||||
|
|
||||||
|
# A rate of 0.0 is not an export level - "no export" is expressed by the
|
||||||
|
# other battery states - so the lower bound is exclusive.
|
||||||
|
if (arr <= 0.0).any() or (arr > 1.0).any():
|
||||||
|
raise ValueError("grid_export_rates must be within ]0.0, 1.0].")
|
||||||
|
|
||||||
|
arr = np.unique(arr)
|
||||||
|
arr.sort()
|
||||||
|
|
||||||
|
return arr
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def measurement_key_soc_factor(self) -> str:
|
def measurement_key_soc_factor(self) -> str:
|
||||||
|
|||||||
@@ -173,6 +173,32 @@ class ConsumerScheduleMode(StrEnum):
|
|||||||
DAILY = "DAILY"
|
DAILY = "DAILY"
|
||||||
|
|
||||||
|
|
||||||
|
class ConsumerDeadlinePolicy(StrEnum):
|
||||||
|
"""Behaviour when a flexible consumer's deadline cannot be met.
|
||||||
|
|
||||||
|
A deadline (``deadline_datetime``) demands that a complete run has *finished*
|
||||||
|
before that moment. Depending on "now", the run duration, the optimization
|
||||||
|
horizon and the allowed time windows, no such start may exist.
|
||||||
|
|
||||||
|
Policies
|
||||||
|
--------
|
||||||
|
- BEST_EFFORT:
|
||||||
|
Run as early as the remaining constraints allow, i.e. minimize the
|
||||||
|
delay instead of the cost ("it should have been done by 03:00, so
|
||||||
|
start now"). A warning is logged. This keeps an optimization request
|
||||||
|
answerable instead of failing it - the usual choice for home
|
||||||
|
automation.
|
||||||
|
|
||||||
|
- STRICT:
|
||||||
|
Keep the deadline. A ONCE consumer without a feasible start makes the
|
||||||
|
optimization fail; a DAILY consumer is simply not scheduled on days
|
||||||
|
without a feasible start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BEST_EFFORT = "BEST_EFFORT"
|
||||||
|
STRICT = "STRICT"
|
||||||
|
|
||||||
|
|
||||||
class ApplianceOperationMode(StrEnum):
|
class ApplianceOperationMode(StrEnum):
|
||||||
"""Appliance operation modes.
|
"""Appliance operation modes.
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ class Battery:
|
|||||||
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
self._discharged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||||
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
self._charged_raw_wh_per_slot = np.zeros(self.prediction_hours, dtype=float)
|
||||||
|
|
||||||
|
def rated_discharge_energy_wh(self) -> float:
|
||||||
|
"""Return the DC energy one full-power discharge slot delivers.
|
||||||
|
|
||||||
|
This is the reference a grid-export rate is applied to: a rate of 0.5
|
||||||
|
exports at most half the battery's rated discharge power, independent of
|
||||||
|
how much of the slot budget self-consumption already used.
|
||||||
|
"""
|
||||||
|
return self.max_charge_power_w * self.slot_duration_h * self.discharging_efficiency
|
||||||
|
|
||||||
def remaining_discharge_energy_wh(self, hour: int) -> float:
|
def remaining_discharge_energy_wh(self, hour: int) -> float:
|
||||||
"""Return DC energy still deliverable within one optimization slot."""
|
"""Return DC energy still deliverable within one optimization slot."""
|
||||||
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
|
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ energy at the chosen start(s). Several runs (DAILY mode) and several devices may
|
|||||||
overlap; their energies simply add up.
|
overlap; their energies simply add up.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import TimeWindowSequence
|
from akkudoktoreos.config.configabc import TimeWindowSequence
|
||||||
from akkudoktoreos.devices.devicesabc import ConsumerScheduleMode
|
from akkudoktoreos.devices.devicesabc import ConsumerDeadlinePolicy, ConsumerScheduleMode
|
||||||
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
|
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
|
||||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
|
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
|
||||||
|
|
||||||
@@ -95,6 +97,12 @@ class HomeAppliance:
|
|||||||
self.device_id: str = parameters.device_id
|
self.device_id: str = parameters.device_id
|
||||||
self.schedule_mode: ConsumerScheduleMode = parameters.schedule_mode
|
self.schedule_mode: ConsumerScheduleMode = parameters.schedule_mode
|
||||||
self.time_windows: Optional[TimeWindowSequence] = parameters.time_windows
|
self.time_windows: Optional[TimeWindowSequence] = parameters.time_windows
|
||||||
|
self.earliest_start_datetime: Optional[DateTime] = parameters.earliest_start_datetime
|
||||||
|
self.deadline_datetime: Optional[DateTime] = parameters.deadline_datetime
|
||||||
|
self.deadline_policy: ConsumerDeadlinePolicy = parameters.deadline_policy
|
||||||
|
# Set when a BEST_EFFORT deadline had to be dropped in the last
|
||||||
|
# allowed_start_slots() call, so callers can report the miss.
|
||||||
|
self.deadline_relaxed: bool = False
|
||||||
self._build_run_profile()
|
self._build_run_profile()
|
||||||
self.reset_load_curve()
|
self.reset_load_curve()
|
||||||
|
|
||||||
@@ -119,6 +127,25 @@ class HomeAppliance:
|
|||||||
)
|
)
|
||||||
self.run_slots: int = int(len(self.run_energy_wh))
|
self.run_slots: int = int(len(self.run_energy_wh))
|
||||||
|
|
||||||
|
def _slot_offset(self, moment: DateTime, slot0_datetime: DateTime, *, round_up: bool) -> int:
|
||||||
|
"""Convert an absolute moment into a slot index relative to slot 0.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
moment: Absolute moment; converted into ``slot0_datetime``'s timezone.
|
||||||
|
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||||
|
round_up: ``True`` returns the first slot boundary at or after
|
||||||
|
``moment`` (lower bounds), ``False`` the last one at or before
|
||||||
|
it (upper bounds).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Slot index (may be negative or beyond the grid; callers clamp).
|
||||||
|
"""
|
||||||
|
seconds = (moment.in_timezone(slot0_datetime.timezone) - slot0_datetime).total_seconds()
|
||||||
|
exact = seconds / self.slot_interval_seconds
|
||||||
|
# Tolerance absorbs float noise so a moment that sits exactly on a slot
|
||||||
|
# boundary is not pushed to the neighbouring slot.
|
||||||
|
return math.ceil(exact - 1e-9) if round_up else math.floor(exact + 1e-9)
|
||||||
|
|
||||||
def allowed_start_slots(
|
def allowed_start_slots(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -128,12 +155,21 @@ class HomeAppliance:
|
|||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""Return the sorted absolute start slots at which a full run is allowed.
|
"""Return the sorted absolute start slots at which a full run is allowed.
|
||||||
|
|
||||||
A start slot ``s`` is allowed when the complete run fits both the
|
A start slot ``s`` is allowed when the complete run fits the optimization
|
||||||
optimization horizon and (if configured) a single allowed time window:
|
horizon, both absolute bounds and (if configured) a single allowed time
|
||||||
|
window:
|
||||||
|
|
||||||
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
|
- ``earliest_slot <= s`` and ``s + run_slots <= horizon_end_slot``
|
||||||
|
- with ``earliest_start_datetime`` set, the run starts at or after it
|
||||||
|
- with ``deadline_datetime`` set, the run *ends* at or before it
|
||||||
- with ``time_windows`` set, the run's whole occupied span starting at
|
- with ``time_windows`` set, the run's whole occupied span starting at
|
||||||
``s`` is contained in one window (respecting weekday/date constraints).
|
``s`` is contained in one window (respecting weekday/date constraints)
|
||||||
|
|
||||||
|
When a deadline leaves no start at all and the policy is
|
||||||
|
``BEST_EFFORT``, the deadline is dropped and only the earliest still
|
||||||
|
possible start is offered (a warning is logged and ``deadline_relaxed``
|
||||||
|
is set): the run happens too late anyway, so it is scheduled with the
|
||||||
|
smallest possible delay instead of at the cheapest slot.
|
||||||
|
|
||||||
No snapping is performed: every returned slot is a genuinely valid start.
|
No snapping is performed: every returned slot is a genuinely valid start.
|
||||||
|
|
||||||
@@ -142,14 +178,83 @@ class HomeAppliance:
|
|||||||
earliest_slot: First slot the optimizer may schedule at ("now").
|
earliest_slot: First slot the optimizer may schedule at ("now").
|
||||||
horizon_end_slot: Exclusive upper bound; a run must end at or before.
|
horizon_end_slot: Exclusive upper bound; a run must end at or before.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of allowed absolute start slots (may be empty).
|
||||||
|
"""
|
||||||
|
self.deadline_relaxed = False
|
||||||
|
allowed = self._allowed_start_slots(
|
||||||
|
slot0_datetime=slot0_datetime,
|
||||||
|
earliest_slot=earliest_slot,
|
||||||
|
horizon_end_slot=horizon_end_slot,
|
||||||
|
apply_deadline=True,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
allowed
|
||||||
|
or self.deadline_datetime is None
|
||||||
|
or self.deadline_policy == ConsumerDeadlinePolicy.STRICT
|
||||||
|
):
|
||||||
|
return allowed
|
||||||
|
|
||||||
|
relaxed = self._allowed_start_slots(
|
||||||
|
slot0_datetime=slot0_datetime,
|
||||||
|
earliest_slot=earliest_slot,
|
||||||
|
horizon_end_slot=horizon_end_slot,
|
||||||
|
apply_deadline=False,
|
||||||
|
)
|
||||||
|
if not relaxed:
|
||||||
|
return relaxed
|
||||||
|
self.deadline_relaxed = True
|
||||||
|
# Keep only the earliest possible start: the deadline is already missed,
|
||||||
|
# so the run is scheduled as soon as possible rather than as cheap as
|
||||||
|
# possible.
|
||||||
|
earliest = relaxed[:1]
|
||||||
|
logger.warning(
|
||||||
|
"Home appliance '{}': deadline {} can not be met - running as early as "
|
||||||
|
"possible instead (BEST_EFFORT). Run ends {}.",
|
||||||
|
self.device_id,
|
||||||
|
self.deadline_datetime,
|
||||||
|
self.run_end_datetime(earliest[0], slot0_datetime),
|
||||||
|
)
|
||||||
|
return earliest
|
||||||
|
|
||||||
|
def _allowed_start_slots(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
slot0_datetime: DateTime,
|
||||||
|
earliest_slot: int,
|
||||||
|
horizon_end_slot: int,
|
||||||
|
apply_deadline: bool,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Compute the allowed start slots for one set of constraints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||||
|
earliest_slot: First slot the optimizer may schedule at ("now").
|
||||||
|
horizon_end_slot: Exclusive upper bound; a run must end at or before.
|
||||||
|
apply_deadline: Whether ``deadline_datetime`` restricts the run end.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sorted list of allowed absolute start slots (may be empty).
|
Sorted list of allowed absolute start slots (may be empty).
|
||||||
"""
|
"""
|
||||||
run_slots = self.run_slots
|
run_slots = self.run_slots
|
||||||
if run_slots <= 0:
|
if run_slots <= 0:
|
||||||
return []
|
return []
|
||||||
last_start = min(horizon_end_slot, self.total_slots) - run_slots
|
|
||||||
first_start = max(earliest_slot, 0)
|
first_start = max(earliest_slot, 0)
|
||||||
|
if self.earliest_start_datetime is not None:
|
||||||
|
first_start = max(
|
||||||
|
first_start,
|
||||||
|
self._slot_offset(self.earliest_start_datetime, slot0_datetime, round_up=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
end_bound = min(horizon_end_slot, self.total_slots)
|
||||||
|
if apply_deadline and self.deadline_datetime is not None:
|
||||||
|
end_bound = min(
|
||||||
|
end_bound,
|
||||||
|
self._slot_offset(self.deadline_datetime, slot0_datetime, round_up=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
last_start = end_bound - run_slots
|
||||||
if last_start < first_start:
|
if last_start < first_start:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -164,6 +269,41 @@ class HomeAppliance:
|
|||||||
allowed.append(slot)
|
allowed.append(slot)
|
||||||
return allowed
|
return allowed
|
||||||
|
|
||||||
|
def run_end_datetime(self, start_slot: int, slot0_datetime: DateTime) -> DateTime:
|
||||||
|
"""Absolute local moment at which a run started at ``start_slot`` finishes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_slot: Absolute start slot of the run.
|
||||||
|
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
End datetime of the run (exclusive, i.e. the first free moment).
|
||||||
|
"""
|
||||||
|
return slot0_datetime.add(
|
||||||
|
seconds=(start_slot + self.run_slots) * self.slot_interval_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
def deadline_missed(self, starts: list[int], slot0_datetime: DateTime) -> bool:
|
||||||
|
"""Whether the scheduled runs violate the configured deadline.
|
||||||
|
|
||||||
|
Without a deadline nothing can be missed. With one, a consumer that was
|
||||||
|
not scheduled at all, or whose run ends after the deadline (a relaxed
|
||||||
|
BEST_EFFORT deadline), counts as missed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
starts: Absolute start slots of the scheduled runs.
|
||||||
|
slot0_datetime: Local, timezone-aware datetime of slot index 0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the deadline is set and not met.
|
||||||
|
"""
|
||||||
|
if self.deadline_datetime is None:
|
||||||
|
return False
|
||||||
|
if not starts:
|
||||||
|
return True
|
||||||
|
deadline = self.deadline_datetime.in_timezone(slot0_datetime.timezone)
|
||||||
|
return any(self.run_end_datetime(start, slot0_datetime) > deadline for start in starts)
|
||||||
|
|
||||||
def build_load_curve(self, starts: list[int]) -> None:
|
def build_load_curve(self, starts: list[int]) -> None:
|
||||||
"""Place the resampled run energy at each decoded start slot.
|
"""Place the resampled run energy at each decoded start slot.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class Inverter:
|
|||||||
consumption: float,
|
consumption: float,
|
||||||
hour: int,
|
hour: int,
|
||||||
allow_battery_grid_export: bool = False,
|
allow_battery_grid_export: bool = False,
|
||||||
|
battery_grid_export_factor: float = 1.0,
|
||||||
) -> tuple[float, float, float, float]:
|
) -> tuple[float, float, float, float]:
|
||||||
"""Process one slot using probabilistic direct PV-to-load overlap.
|
"""Process one slot using probabilistic direct PV-to-load overlap.
|
||||||
|
|
||||||
@@ -59,6 +60,17 @@ class Inverter:
|
|||||||
PV-to-load power. The remaining load and PV surplus are then handled
|
PV-to-load power. The remaining load and PV surplus are then handled
|
||||||
independently, because both can occur during different sub-intervals of
|
independently, because both can occur during different sub-intervals of
|
||||||
the same hourly or 15-minute slot.
|
the same hourly or 15-minute slot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generation: PV energy of the slot [Wh].
|
||||||
|
consumption: Load energy of the slot [Wh].
|
||||||
|
hour: Slot index.
|
||||||
|
allow_battery_grid_export: Whether the battery may discharge into the
|
||||||
|
grid in this slot (direct marketing).
|
||||||
|
battery_grid_export_factor: Export level as a factor of the battery's
|
||||||
|
rated discharge power [0.0 ... 1.0]. 1.0 exports as much as the
|
||||||
|
battery and the inverter allow, which is the behaviour when no
|
||||||
|
export rates are configured.
|
||||||
"""
|
"""
|
||||||
losses = 0.0
|
losses = 0.0
|
||||||
grid_export = 0.0
|
grid_export = 0.0
|
||||||
@@ -123,10 +135,20 @@ class Inverter:
|
|||||||
losses += max(remaining_surplus - pv_grid_export, 0.0)
|
losses += max(remaining_surplus - pv_grid_export, 0.0)
|
||||||
|
|
||||||
if allow_battery_grid_export and self.battery and remaining_inverter_ac_capacity > 0.0:
|
if allow_battery_grid_export and self.battery and remaining_inverter_ac_capacity > 0.0:
|
||||||
|
export_factor = min(max(float(battery_grid_export_factor), 0.0), 1.0)
|
||||||
remaining_battery_ac = (
|
remaining_battery_ac = (
|
||||||
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
|
self.battery.remaining_discharge_energy_wh(hour) * self.dc_to_ac_efficiency
|
||||||
)
|
)
|
||||||
export_capacity = min(remaining_inverter_ac_capacity, remaining_battery_ac)
|
# The rate caps the export against the battery's *rated* discharge
|
||||||
|
# power, so it stays a plain power setpoint ("export at 50 %") that
|
||||||
|
# does not silently grow when self-consumption used less of the slot.
|
||||||
|
# At factor 1.0 this bound never binds; behaviour is unchanged.
|
||||||
|
rated_export_ac = (
|
||||||
|
self.battery.rated_discharge_energy_wh() * export_factor * self.dc_to_ac_efficiency
|
||||||
|
)
|
||||||
|
export_capacity = min(
|
||||||
|
remaining_inverter_ac_capacity, remaining_battery_ac, rated_export_ac
|
||||||
|
)
|
||||||
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
|
battery_export_ac, battery_export_losses = self._discharge_battery_to_ac(
|
||||||
export_capacity, hour
|
export_capacity, hour
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Genetic algorithm."""
|
"""Genetic algorithm."""
|
||||||
|
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
@@ -87,13 +88,21 @@ class FitnessCacheEntry:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BatteryStateLayout:
|
class BatteryStateLayout:
|
||||||
"""Indices of optional battery states appended to the legacy state ranges."""
|
"""Indices of optional battery states appended to the legacy state ranges.
|
||||||
|
|
||||||
|
With graded direct-marketing export there is one state per configured export
|
||||||
|
rate. ``grid_export_states`` holds them in the order of
|
||||||
|
``bat_possible_grid_export_values`` (full power first), and
|
||||||
|
``grid_export_state`` is that full-power state - the one every seeding
|
||||||
|
heuristic uses when it wants "export in this slot".
|
||||||
|
"""
|
||||||
|
|
||||||
total_states: int
|
total_states: int
|
||||||
dc_not_allowed_state: Optional[int] = None
|
dc_not_allowed_state: Optional[int] = None
|
||||||
dc_allowed_state: Optional[int] = None
|
dc_allowed_state: Optional[int] = None
|
||||||
grid_export_state: Optional[int] = None
|
grid_export_state: Optional[int] = None
|
||||||
self_consumption_state: Optional[int] = None
|
self_consumption_state: Optional[int] = None
|
||||||
|
grid_export_states: tuple[int, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class GeneticSimulation(PydanticBaseModel):
|
class GeneticSimulation(PydanticBaseModel):
|
||||||
@@ -446,10 +455,14 @@ class GeneticSimulation(PydanticBaseModel):
|
|||||||
if inverter_fast:
|
if inverter_fast:
|
||||||
energy_produced = pv_prediction_wh_fast[hour]
|
energy_produced = pv_prediction_wh_fast[hour]
|
||||||
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
||||||
|
# bat_grid_export_hours carries the export level per slot:
|
||||||
|
# 0.0 = no export, otherwise the factor of the rated discharge
|
||||||
|
# power the optimizer selected.
|
||||||
|
battery_grid_export_factor = float(bat_grid_export_hours_fast[hour])
|
||||||
battery_grid_export_allowed = (
|
battery_grid_export_allowed = (
|
||||||
direct_marketing_enabled_fast
|
direct_marketing_enabled_fast
|
||||||
and hourly_feed_in_tariff > 0.0
|
and hourly_feed_in_tariff > 0.0
|
||||||
and bat_grid_export_hours_fast[hour] > 0
|
and battery_grid_export_factor > 0.0
|
||||||
)
|
)
|
||||||
(
|
(
|
||||||
energy_feedin_grid_actual,
|
energy_feedin_grid_actual,
|
||||||
@@ -461,6 +474,7 @@ class GeneticSimulation(PydanticBaseModel):
|
|||||||
consumption,
|
consumption,
|
||||||
hour,
|
hour,
|
||||||
allow_battery_grid_export=battery_grid_export_allowed,
|
allow_battery_grid_export=battery_grid_export_allowed,
|
||||||
|
battery_grid_export_factor=battery_grid_export_factor,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
hourly_feed_in_tariff = elect_revenue_per_hour_arr_fast[hour]
|
||||||
@@ -648,6 +662,13 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
# Separate charge-level list for battery AC charging (independent of EV rates).
|
# Separate charge-level list for battery AC charging (independent of EV rates).
|
||||||
# Populated from parameters.pv_akku.charge_rates in optimierung_ems.
|
# Populated from parameters.pv_akku.charge_rates in optimierung_ems.
|
||||||
self.bat_possible_charge_values: list[float] = [1.0]
|
self.bat_possible_charge_values: list[float] = [1.0]
|
||||||
|
# Battery-to-grid export levels (direct marketing), full power first.
|
||||||
|
# Populated from parameters.pv_akku.grid_export_rates in optimierung_ems;
|
||||||
|
# the single full-power default keeps the all-or-nothing export.
|
||||||
|
self.bat_possible_grid_export_values: list[float] = [1.0]
|
||||||
|
# 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
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.fix_seed = fixed_seed
|
self.fix_seed = fixed_seed
|
||||||
self.optimize_ev = True
|
self.optimize_ev = True
|
||||||
@@ -709,9 +730,14 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
dc_allowed_state = next_state + 1
|
dc_allowed_state = next_state + 1
|
||||||
next_state += 2
|
next_state += 2
|
||||||
|
|
||||||
|
grid_export_states: tuple[int, ...] = ()
|
||||||
if self.optimize_battery_grid_export:
|
if self.optimize_battery_grid_export:
|
||||||
grid_export_state = next_state
|
export_count = max(len(self.bat_possible_grid_export_values), 1)
|
||||||
next_state += 1
|
grid_export_states = tuple(range(next_state, next_state + export_count))
|
||||||
|
# The first export state stays the full-power one, so its index does
|
||||||
|
# not move when further rates are configured.
|
||||||
|
grid_export_state = grid_export_states[0]
|
||||||
|
next_state += export_count
|
||||||
|
|
||||||
if self.optimize_dc_charge:
|
if self.optimize_dc_charge:
|
||||||
self_consumption_state = next_state
|
self_consumption_state = next_state
|
||||||
@@ -723,6 +749,7 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
dc_allowed_state=dc_allowed_state,
|
dc_allowed_state=dc_allowed_state,
|
||||||
grid_export_state=grid_export_state,
|
grid_export_state=grid_export_state,
|
||||||
self_consumption_state=self_consumption_state,
|
self_consumption_state=self_consumption_state,
|
||||||
|
grid_export_states=grid_export_states,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _appliance_horizon_end_slot(self) -> int:
|
def _appliance_horizon_end_slot(self) -> int:
|
||||||
@@ -736,6 +763,50 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour
|
horizon_slots = self.config.optimization.horizon_hours * self.slots_per_hour
|
||||||
return min(self.total_slots, start_slot + horizon_slots)
|
return min(self.total_slots, start_slot + horizon_slots)
|
||||||
|
|
||||||
|
def _ev_deadline_slot(self, parameters: GeneticOptimizationParameters) -> Optional[int]:
|
||||||
|
"""Slot index by which the EV has to reach ``min_soc_percentage``.
|
||||||
|
|
||||||
|
The deadline may be given as an absolute datetime, as a maximum duration
|
||||||
|
from the start of the optimization, or both - then the earlier one wins.
|
||||||
|
The returned slot is the first one that starts at or after the deadline,
|
||||||
|
so all charging that completes before the deadline still counts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parameters: Optimization parameters of this run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute slot index, or None when the target is only required at the
|
||||||
|
end of the horizon (no deadline, or one beyond the horizon).
|
||||||
|
"""
|
||||||
|
ev_parameters = parameters.eauto
|
||||||
|
if ev_parameters is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
start_slot = self._start_day_slot()
|
||||||
|
slot_seconds = self.slot_duration_h * 3600
|
||||||
|
candidates: list[int] = []
|
||||||
|
|
||||||
|
deadline = ev_parameters.min_soc_deadline_datetime
|
||||||
|
if deadline is not None:
|
||||||
|
seconds = (
|
||||||
|
deadline.in_timezone(self._slot0_datetime.timezone) - self._slot0_datetime
|
||||||
|
).total_seconds()
|
||||||
|
candidates.append(math.ceil(seconds / slot_seconds - 1e-9))
|
||||||
|
|
||||||
|
duration_h = ev_parameters.min_soc_max_duration_h
|
||||||
|
if duration_h is not None:
|
||||||
|
candidates.append(start_slot + math.ceil(duration_h * 3600 / slot_seconds - 1e-9))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
deadline_slot = min(candidates)
|
||||||
|
if deadline_slot >= self.total_slots:
|
||||||
|
# Beyond the horizon: the end-of-horizon requirement already covers it.
|
||||||
|
return None
|
||||||
|
# A deadline in the past means the target is due right now.
|
||||||
|
return max(deadline_slot, start_slot)
|
||||||
|
|
||||||
def _build_appliance_layout(
|
def _build_appliance_layout(
|
||||||
self, appliances: list[HomeAppliance], slot0_datetime: Any
|
self, appliances: list[HomeAppliance], slot0_datetime: Any
|
||||||
) -> ApplianceGeneLayout:
|
) -> ApplianceGeneLayout:
|
||||||
@@ -762,7 +833,8 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
if not allowed:
|
if not allowed:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Home appliance '{appliance.device_id}' (ONCE) has no valid "
|
f"Home appliance '{appliance.device_id}' (ONCE) has no valid "
|
||||||
f"start slot within the optimization horizon and its time windows."
|
f"start slot within the optimization horizon, its time windows "
|
||||||
|
f"and its deadline."
|
||||||
)
|
)
|
||||||
genes.append(
|
genes.append(
|
||||||
ApplianceGeneSlot(
|
ApplianceGeneSlot(
|
||||||
@@ -1056,10 +1128,17 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float)
|
ac_charge = np.zeros_like(discharge_hours_bin_np, dtype=float)
|
||||||
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices]
|
ac_charge[ac_mask] = [self.bat_possible_charge_values[i] for i in ac_indices]
|
||||||
|
|
||||||
battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=int)
|
# Export rate per slot: 0.0 = no export, otherwise the factor of the
|
||||||
if state_layout.grid_export_state is not None:
|
# rated discharge power the optimizer picked for that slot.
|
||||||
|
battery_grid_export = np.zeros_like(discharge_hours_bin_np, dtype=float)
|
||||||
|
for index, export_state in enumerate(state_layout.grid_export_states):
|
||||||
|
rate = (
|
||||||
|
self.bat_possible_grid_export_values[index]
|
||||||
|
if index < len(self.bat_possible_grid_export_values)
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
battery_grid_export = np.where(
|
battery_grid_export = np.where(
|
||||||
discharge_hours_bin_np == state_layout.grid_export_state, 1, 0
|
discharge_hours_bin_np == export_state, rate, battery_grid_export
|
||||||
)
|
)
|
||||||
|
|
||||||
# Idle is just 0, already default.
|
# Idle is just 0, already default.
|
||||||
@@ -1079,8 +1158,8 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
policy_states.append(state_layout.self_consumption_state)
|
policy_states.append(state_layout.self_consumption_state)
|
||||||
if state_layout.dc_allowed_state is not None:
|
if state_layout.dc_allowed_state is not None:
|
||||||
policy_states.append(state_layout.dc_allowed_state)
|
policy_states.append(state_layout.dc_allowed_state)
|
||||||
if state_layout.grid_export_state is not None:
|
# Every export level is a coherent policy for a whole block.
|
||||||
policy_states.append(state_layout.grid_export_state)
|
policy_states.extend(state_layout.grid_export_states)
|
||||||
|
|
||||||
block_start = random.randint(start_slot, self.total_slots - 1) # noqa: S311
|
block_start = random.randint(start_slot, self.total_slots - 1) # noqa: S311
|
||||||
max_length = min(12, self.total_slots - block_start)
|
max_length = min(12, self.total_slots - block_start)
|
||||||
@@ -1122,15 +1201,15 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
def _mutate_energy_shift(self, individual: list[int]) -> bool:
|
def _mutate_energy_shift(self, individual: list[int]) -> bool:
|
||||||
"""Move battery energy from a weak export into later expensive self-consumption."""
|
"""Move battery energy from a weak export into later expensive self-consumption."""
|
||||||
state_layout = self._battery_state_layout()
|
state_layout = self._battery_state_layout()
|
||||||
export_state = state_layout.grid_export_state
|
export_states = set(state_layout.grid_export_states)
|
||||||
self_state = state_layout.self_consumption_state
|
self_state = state_layout.self_consumption_state
|
||||||
if export_state is None or self_state is None:
|
if not export_states or self_state is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
start_slot = self._start_day_slot()
|
start_slot = self._start_day_slot()
|
||||||
viable: list[tuple[int, list[int]]] = []
|
viable: list[tuple[int, list[int]]] = []
|
||||||
for source_slot in range(start_slot, self.total_slots):
|
for source_slot in range(start_slot, self.total_slots):
|
||||||
if int(individual[source_slot]) != export_state:
|
if int(individual[source_slot]) not in export_states:
|
||||||
continue
|
continue
|
||||||
targets = self._energy_shift_target_slots(individual, source_slot)
|
targets = self._energy_shift_target_slots(individual, source_slot)
|
||||||
if targets:
|
if targets:
|
||||||
@@ -1420,6 +1499,9 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
|
|
||||||
start_slot = self._start_day_slot()
|
start_slot = self._start_day_slot()
|
||||||
end_slot = max(start_slot, self.total_slots - self.fixed_eauto_hours)
|
end_slot = max(start_slot, self.total_slots - self.fixed_eauto_hours)
|
||||||
|
if getattr(self, "_ev_soc_deadline_slot", None) is not None:
|
||||||
|
# Charging after the deadline does not help to reach the target.
|
||||||
|
end_slot = max(start_slot, min(end_slot, self._ev_soc_deadline_slot))
|
||||||
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
|
prices = np.asarray(self.simulation.elect_price_hourly, dtype=float)
|
||||||
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
|
feed_in = np.asarray(self.simulation.elect_revenue_per_hour_arr, dtype=float)
|
||||||
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
|
pv = np.asarray(self.simulation.pv_prediction_wh, dtype=float)
|
||||||
@@ -1692,9 +1774,9 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
) -> list[list[int]]:
|
) -> list[list[int]]:
|
||||||
"""Build deterministic export-to-self-consumption neighbourhood candidates."""
|
"""Build deterministic export-to-self-consumption neighbourhood candidates."""
|
||||||
state_layout = self._battery_state_layout()
|
state_layout = self._battery_state_layout()
|
||||||
export_state = state_layout.grid_export_state
|
export_states = set(state_layout.grid_export_states)
|
||||||
self_state = state_layout.self_consumption_state
|
self_state = state_layout.self_consumption_state
|
||||||
if export_state is None or self_state is None:
|
if not export_states or self_state is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
start_slot = self._start_day_slot()
|
start_slot = self._start_day_slot()
|
||||||
@@ -1709,7 +1791,7 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
sources = [
|
sources = [
|
||||||
slot
|
slot
|
||||||
for slot in range(start_slot, self.total_slots)
|
for slot in range(start_slot, self.total_slots)
|
||||||
if int(individual[slot]) == export_state
|
if int(individual[slot]) in export_states
|
||||||
]
|
]
|
||||||
# Search weak and late export decisions first. They are the most likely
|
# Search weak and late export decisions first. They are the most likely
|
||||||
# to compete with later, more valuable avoided grid imports.
|
# to compete with later, more valuable avoided grid imports.
|
||||||
@@ -2218,6 +2300,35 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
relevant.extend(individual[-n_appliance_genes:])
|
relevant.extend(individual[-n_appliance_genes:])
|
||||||
return tuple(int(value) for value in relevant)
|
return tuple(int(value) for value in relevant)
|
||||||
|
|
||||||
|
def _ev_soc_at_deadline(self, simulation_result: dict[str, Any], start_slot: int) -> float:
|
||||||
|
"""EV state of charge the target is checked against [%].
|
||||||
|
|
||||||
|
Without a deadline this is the SoC after the last slot, which is what the
|
||||||
|
penalty always used. With a deadline it is the SoC at the beginning of
|
||||||
|
the deadline slot, i.e. after every charge that completes in time.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
ev = self.simulation.ev
|
||||||
|
if ev is None:
|
||||||
|
return 0.0
|
||||||
|
# Lightweight callers construct the optimizer without __init__ (see
|
||||||
|
# evaluate()); a missing deadline must behave like no deadline.
|
||||||
|
deadline_slot = getattr(self, "_ev_soc_deadline_slot", None)
|
||||||
|
if deadline_slot is None:
|
||||||
|
return ev.current_soc_percentage()
|
||||||
|
|
||||||
|
soc_per_slot = simulation_result.get("EAuto_SoC_pro_Stunde")
|
||||||
|
index = deadline_slot - start_slot
|
||||||
|
if soc_per_slot is None or index >= len(soc_per_slot):
|
||||||
|
return ev.current_soc_percentage()
|
||||||
|
return float(soc_per_slot[max(index, 0)])
|
||||||
|
|
||||||
def _evaluate_uncached(
|
def _evaluate_uncached(
|
||||||
self,
|
self,
|
||||||
individual: list[int],
|
individual: list[int],
|
||||||
@@ -2432,7 +2543,7 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
logger.error(
|
logger.error(
|
||||||
"Penalty function parameter `ev_soc_miss` not configured, using {}.", penalty
|
"Penalty function parameter `ev_soc_miss` not configured, using {}.", penalty
|
||||||
)
|
)
|
||||||
ev_soc_percentage = self.simulation.ev.current_soc_percentage()
|
ev_soc_percentage = self._ev_soc_at_deadline(simulation_result, start_hour)
|
||||||
if ev_soc_percentage < parameters.eauto.min_soc_percentage:
|
if ev_soc_percentage < parameters.eauto.min_soc_percentage:
|
||||||
gesamtbilanz += (
|
gesamtbilanz += (
|
||||||
abs(parameters.eauto.min_soc_percentage - ev_soc_percentage) * penalty
|
abs(parameters.eauto.min_soc_percentage - ev_soc_percentage) * penalty
|
||||||
@@ -2773,6 +2884,26 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
self.bat_possible_charge_values = [1.0]
|
self.bat_possible_charge_values = [1.0]
|
||||||
logger.debug("Battery AC charge levels: {}", self.bat_possible_charge_values)
|
logger.debug("Battery AC charge levels: {}", self.bat_possible_charge_values)
|
||||||
|
|
||||||
|
# Battery-to-grid export levels (direct marketing only). Same resolution
|
||||||
|
# order as the charge rates: request parameters win over the configured
|
||||||
|
# battery, and the fallback is the previous all-or-nothing export.
|
||||||
|
export_rates: Optional[list[float]] = None
|
||||||
|
if parameters.pv_akku and parameters.pv_akku.grid_export_rates:
|
||||||
|
export_rates = list(parameters.pv_akku.grid_export_rates)
|
||||||
|
elif (
|
||||||
|
self.config.devices.batteries
|
||||||
|
and self.config.devices.batteries[0]
|
||||||
|
and self.config.devices.batteries[0].grid_export_rates is not None
|
||||||
|
):
|
||||||
|
export_rates = list(self.config.devices.batteries[0].grid_export_rates)
|
||||||
|
# Highest rate first so the full-power state keeps the lowest index and
|
||||||
|
# every heuristic that seeds "export here" keeps seeding full power.
|
||||||
|
self.bat_possible_grid_export_values = sorted(
|
||||||
|
(rate for rate in (export_rates or [1.0]) if rate > 0.0), reverse=True
|
||||||
|
) or [1.0]
|
||||||
|
if self.optimize_battery_grid_export:
|
||||||
|
logger.debug("Battery grid export levels: {}", self.bat_possible_grid_export_values)
|
||||||
|
|
||||||
# Initialize the flexible consumers (home appliances) and their genome
|
# Initialize the flexible consumers (home appliances) and their genome
|
||||||
# layout. slot0_datetime (midnight of the start day) turns decoded start
|
# layout. slot0_datetime (midnight of the start day) turns decoded start
|
||||||
# slots into absolute local timestamps and drives DAILY day grouping.
|
# slots into absolute local timestamps and drives DAILY day grouping.
|
||||||
@@ -2790,6 +2921,18 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
]
|
]
|
||||||
self.appliance_layout = self._build_appliance_layout(home_appliances, self._slot0_datetime)
|
self.appliance_layout = self._build_appliance_layout(home_appliances, self._slot0_datetime)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
if self._ev_soc_deadline_slot is not None:
|
||||||
|
logger.debug(
|
||||||
|
"EV target SoC required by slot {} ({}).",
|
||||||
|
self._ev_soc_deadline_slot,
|
||||||
|
self._slot0_datetime.add(
|
||||||
|
seconds=self._ev_soc_deadline_slot * self.slot_duration_h * 3600
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize the inverter and energy management system. slot_duration_h
|
# Initialize the inverter and energy management system. slot_duration_h
|
||||||
# lets the Inverter scale max_power_wh to a per-slot energy cap.
|
# lets the Inverter scale max_power_wh to a per-slot energy cap.
|
||||||
inverter: Optional[Inverter] = None
|
inverter: Optional[Inverter] = None
|
||||||
@@ -2844,6 +2987,7 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
|
starts_per_appliance = self._decode_appliance_starts(appliance_gene_values)
|
||||||
home_appliance_energy_wh: dict[str, list[float]] = {}
|
home_appliance_energy_wh: dict[str, list[float]] = {}
|
||||||
appliance_starts: dict[str, list[Any]] = {}
|
appliance_starts: dict[str, list[Any]] = {}
|
||||||
|
appliance_deadline_missed: dict[str, bool] = {}
|
||||||
timezone = self.config.general.timezone
|
timezone = self.config.general.timezone
|
||||||
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
|
for appliance_index, appliance in enumerate(self.simulation.home_appliances):
|
||||||
device_id = appliance.device_id
|
device_id = appliance.device_id
|
||||||
@@ -2855,6 +2999,13 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
).in_timezone(timezone)
|
).in_timezone(timezone)
|
||||||
for start in starts
|
for start in starts
|
||||||
]
|
]
|
||||||
|
# Report a deadline that could not be kept (no run scheduled at all,
|
||||||
|
# or a run that ends late because a BEST_EFFORT deadline was dropped)
|
||||||
|
# so the caller can warn instead of silently trusting the schedule.
|
||||||
|
if appliance.deadline_datetime is not None:
|
||||||
|
appliance_deadline_missed[device_id] = appliance.deadline_missed(
|
||||||
|
starts, self._slot0_datetime
|
||||||
|
)
|
||||||
simulation_result["home_appliance_energy_wh"] = home_appliance_energy_wh
|
simulation_result["home_appliance_energy_wh"] = home_appliance_energy_wh
|
||||||
|
|
||||||
# Deprecated single-device hourly start (kept for backward compatibility).
|
# Deprecated single-device hourly start (kept for backward compatibility).
|
||||||
@@ -2888,9 +3039,13 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
discharge = discharge.tolist()
|
discharge = discharge.tolist()
|
||||||
battery_grid_export = self.simulation.bat_grid_export_hours
|
battery_grid_export = self.simulation.bat_grid_export_hours
|
||||||
if not direct_marketing_enabled or battery_grid_export is None:
|
if not direct_marketing_enabled or battery_grid_export is None:
|
||||||
|
battery_grid_export_factor = []
|
||||||
battery_grid_export = []
|
battery_grid_export = []
|
||||||
else:
|
else:
|
||||||
battery_grid_export = battery_grid_export.tolist()
|
# The simulation array holds the export level; the legacy signal is
|
||||||
|
# its boolean projection.
|
||||||
|
battery_grid_export_factor = [float(value) for value in battery_grid_export]
|
||||||
|
battery_grid_export = [1 if value > 0.0 else 0 for value in battery_grid_export_factor]
|
||||||
|
|
||||||
# Visualize the results in PDF. Skippable via config — matplotlib PDF
|
# Visualize the results in PDF. Skippable via config — matplotlib PDF
|
||||||
# generation costs several seconds per run, which headless setups
|
# generation costs several seconds per run, which headless setups
|
||||||
@@ -2926,11 +3081,13 @@ class GeneticOptimization(OptimizationBase):
|
|||||||
"dc_charge": dc_charge_hours,
|
"dc_charge": dc_charge_hours,
|
||||||
"discharge_allowed": discharge,
|
"discharge_allowed": discharge,
|
||||||
"battery_grid_export_allowed": battery_grid_export,
|
"battery_grid_export_allowed": battery_grid_export,
|
||||||
|
"battery_grid_export_factor": battery_grid_export_factor,
|
||||||
"eautocharge_hours_float": eautocharge_hours_float,
|
"eautocharge_hours_float": eautocharge_hours_float,
|
||||||
"result": GeneticSimulationResult(**simulation_result),
|
"result": GeneticSimulationResult(**simulation_result),
|
||||||
"eauto_obj": self.simulation.ev,
|
"eauto_obj": self.simulation.ev,
|
||||||
"start_solution": start_solution,
|
"start_solution": start_solution,
|
||||||
"washingstart": washingstart_int,
|
"washingstart": washingstart_int,
|
||||||
"appliance_starts": appliance_starts,
|
"appliance_starts": appliance_starts,
|
||||||
|
"appliance_deadline_missed": appliance_deadline_missed,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
"""Genetic optimization algorithm device interfaces/ parameters."""
|
"""Genetic optimization algorithm device interfaces/ parameters."""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import Field, model_validator
|
from pydantic import Field, field_validator, model_validator
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from akkudoktoreos.config.configabc import TimeWindowSequence
|
from akkudoktoreos.config.configabc import TimeWindowSequence
|
||||||
from akkudoktoreos.devices.devicesabc import (
|
from akkudoktoreos.devices.devicesabc import (
|
||||||
|
ConsumerDeadlinePolicy,
|
||||||
ConsumerScheduleMode,
|
ConsumerScheduleMode,
|
||||||
validate_home_appliance_load_definition,
|
validate_home_appliance_load_definition,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
||||||
|
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
|
||||||
|
|
||||||
|
|
||||||
class DeviceParameters(GeneticParametersBaseModel):
|
class DeviceParameters(GeneticParametersBaseModel):
|
||||||
@@ -98,6 +100,18 @@ class BaseBatteryParameters(DeviceParameters):
|
|||||||
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
|
"examples": [[0.0, 0.25, 0.5, 0.75, 1.0], None],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
grid_export_rates: Optional[list[float]] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Battery-to-grid export rates as factor of maximum discharge "
|
||||||
|
"power ]0.00 ... 1.00]. Only used with direct marketing. None "
|
||||||
|
"falls back to the configured devices.batteries[0]."
|
||||||
|
"grid_export_rates."
|
||||||
|
),
|
||||||
|
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SolarPanelBatteryParameters(BaseBatteryParameters):
|
class SolarPanelBatteryParameters(BaseBatteryParameters):
|
||||||
@@ -118,7 +132,13 @@ class SolarPanelBatteryParameters(BaseBatteryParameters):
|
|||||||
|
|
||||||
|
|
||||||
class ElectricVehicleParameters(BaseBatteryParameters):
|
class ElectricVehicleParameters(BaseBatteryParameters):
|
||||||
"""Battery Electric Vehicle Device Simulation Configuration."""
|
"""Battery Electric Vehicle Device Simulation Configuration.
|
||||||
|
|
||||||
|
``min_soc_percentage`` is the charging target. By default it only has to be
|
||||||
|
reached by the end of the optimization horizon; a deadline
|
||||||
|
(``min_soc_deadline_datetime`` and/or ``min_soc_max_duration_h``) moves that
|
||||||
|
requirement forward, for example to the next departure.
|
||||||
|
"""
|
||||||
|
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
|
json_schema_extra={"description": "ID of electric vehicle", "examples": ["ev1"]}
|
||||||
@@ -127,6 +147,37 @@ class ElectricVehicleParameters(BaseBatteryParameters):
|
|||||||
initial_soc_percentage: int = initial_soc_percentage_field(
|
initial_soc_percentage: int = initial_soc_percentage_field(
|
||||||
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
"An integer representing the current state of charge (SOC) of the battery in percentage."
|
||||||
)
|
)
|
||||||
|
min_soc_deadline_datetime: Optional[DateTime] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Absolute moment by which 'min_soc_percentage' has to be "
|
||||||
|
"reached (departure time). A date time without timezone is read "
|
||||||
|
"as local time. None means end of the optimization horizon."
|
||||||
|
),
|
||||||
|
"examples": [None, "2026-07-16T07:00:00+02:00"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
min_soc_max_duration_h: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
gt=0,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Maximum time from the start of the optimization until "
|
||||||
|
"'min_soc_percentage' has to be reached [h]. Combined with "
|
||||||
|
"'min_soc_deadline_datetime' the earlier of the two applies."
|
||||||
|
),
|
||||||
|
"examples": [None, 6.0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("min_soc_deadline_datetime", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def transform_deadline_to_datetime(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)
|
||||||
|
|
||||||
|
|
||||||
class HomeApplianceParameters(DeviceParameters):
|
class HomeApplianceParameters(DeviceParameters):
|
||||||
@@ -136,6 +187,14 @@ class HomeApplianceParameters(DeviceParameters):
|
|||||||
(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)
|
(``load_profile_power_w`` with an optional ``load_profile_interval_seconds``)
|
||||||
**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one
|
**or** by the flat fallback ``consumption_wh`` + ``duration_h``. Exactly one
|
||||||
of the two must be provided.
|
of the two must be provided.
|
||||||
|
|
||||||
|
*When* the run may happen is constrained by three independent mechanisms that
|
||||||
|
all have to hold at once:
|
||||||
|
|
||||||
|
- ``time_windows``: recurring wall-clock windows ("only between 10:00 and 13:00").
|
||||||
|
- ``earliest_start_datetime``: absolute lower bound ("not before I get home").
|
||||||
|
- ``deadline_datetime``: absolute upper bound; the run must be *finished*
|
||||||
|
before that moment ("clean dishes by 03:00 tonight").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device_id: str = Field(
|
device_id: str = Field(
|
||||||
@@ -207,6 +266,49 @@ class HomeApplianceParameters(DeviceParameters):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
earliest_start_datetime: Optional[DateTime] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Absolute earliest moment the run may start. Starts before it are "
|
||||||
|
"dropped, in addition to 'time_windows' and the horizon. A date "
|
||||||
|
"time without timezone is read as local time. This bound is never "
|
||||||
|
"relaxed."
|
||||||
|
),
|
||||||
|
"examples": [None, "2026-07-15T20:00:00+02:00"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
deadline_datetime: Optional[DateTime] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Absolute deadline: the complete run must have *finished* at or "
|
||||||
|
"before this moment (e.g. end of the day, or 03:00 tonight). A "
|
||||||
|
"date time without timezone is read as local time. See "
|
||||||
|
"'deadline_policy' for what happens when no start can meet it."
|
||||||
|
),
|
||||||
|
"examples": [None, "2026-07-16T03:00:00+02:00"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
deadline_policy: ConsumerDeadlinePolicy = Field(
|
||||||
|
default=ConsumerDeadlinePolicy.BEST_EFFORT,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"What to do when 'deadline_datetime' cannot be met: BEST_EFFORT "
|
||||||
|
"runs as early as possible instead (warning logged), STRICT keeps "
|
||||||
|
"the deadline (a ONCE consumer then fails the optimization)."
|
||||||
|
),
|
||||||
|
"examples": ["BEST_EFFORT", "STRICT"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("earliest_start_datetime", "deadline_datetime", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def transform_to_datetime(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")
|
@model_validator(mode="after")
|
||||||
def validate_load_definition(self) -> Self:
|
def validate_load_definition(self) -> Self:
|
||||||
@@ -219,6 +321,17 @@ class HomeApplianceParameters(DeviceParameters):
|
|||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_schedule_bounds(self) -> Self:
|
||||||
|
"""Reject an empty scheduling interval."""
|
||||||
|
if self.earliest_start_datetime is not None and self.deadline_datetime is not None:
|
||||||
|
if compare_datetimes(self.deadline_datetime, self.earliest_start_datetime).le:
|
||||||
|
raise ValueError(
|
||||||
|
f"deadline_datetime {self.deadline_datetime} must be after "
|
||||||
|
f"earliest_start_datetime {self.earliest_start_datetime}."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class InverterParameters(DeviceParameters):
|
class InverterParameters(DeviceParameters):
|
||||||
"""Inverter Device Simulation Configuration."""
|
"""Inverter Device Simulation Configuration."""
|
||||||
|
|||||||
@@ -201,6 +201,18 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
|
"description": "Array with battery-to-grid export values (1 for export discharge, 0 otherwise)."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
battery_grid_export_factor: list[float] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Array with the battery-to-grid export level per slot as factor "
|
||||||
|
"of the rated discharge power (0.0 for no export). Empty when "
|
||||||
|
"direct marketing is disabled; a solution without this array "
|
||||||
|
"exports at full power wherever "
|
||||||
|
"'battery_grid_export_allowed' is 1."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
|
eautocharge_hours_float: Optional[list[float]] = Field(json_schema_extra={"description": "TBD"})
|
||||||
result: GeneticSimulationResult
|
result: GeneticSimulationResult
|
||||||
eauto_obj: Optional[ElectricVehicleResult]
|
eauto_obj: Optional[ElectricVehicleResult]
|
||||||
@@ -229,6 +241,16 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
appliance_deadline_missed: dict[str, bool] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
json_schema_extra={
|
||||||
|
"description": (
|
||||||
|
"Per appliance device_id with a 'deadline_datetime': whether the "
|
||||||
|
"scheduled run misses that deadline (or was not scheduled at "
|
||||||
|
"all). Appliances without a deadline are not listed."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"ac_charge",
|
"ac_charge",
|
||||||
@@ -276,6 +298,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
dc_charge: float,
|
dc_charge: float,
|
||||||
discharge_allowed: bool,
|
discharge_allowed: bool,
|
||||||
battery_grid_export_allowed: bool = False,
|
battery_grid_export_allowed: bool = False,
|
||||||
|
battery_grid_export_factor: float = 1.0,
|
||||||
) -> tuple[BatteryOperationMode, float]:
|
) -> tuple[BatteryOperationMode, float]:
|
||||||
"""Maps low-level solution to a representative operation mode and factor.
|
"""Maps low-level solution to a representative operation mode and factor.
|
||||||
|
|
||||||
@@ -284,6 +307,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
dc_charge (float): Allowed DC-side charging power (relative units).
|
dc_charge (float): Allowed DC-side charging power (relative units).
|
||||||
discharge_allowed (bool): Whether discharging to local load is permitted.
|
discharge_allowed (bool): Whether discharging to local load is permitted.
|
||||||
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
|
battery_grid_export_allowed (bool): Whether discharge into the grid is permitted.
|
||||||
|
battery_grid_export_factor (float): Export level as factor of the rated
|
||||||
|
discharge power ]0.0 ... 1.0]. Becomes the operation factor of
|
||||||
|
GRID_SUPPORT_EXPORT.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[BatteryOperationMode, float]: A tuple containing
|
tuple[BatteryOperationMode, float]: A tuple containing
|
||||||
@@ -310,7 +336,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
|
"Illegal state: battery_grid_export_allowed cannot be combined with charging"
|
||||||
)
|
)
|
||||||
return BatteryOperationMode.GRID_SUPPORT_EXPORT, 1.0
|
return BatteryOperationMode.GRID_SUPPORT_EXPORT, min(
|
||||||
|
max(float(battery_grid_export_factor), 0.0), 1.0
|
||||||
|
)
|
||||||
|
|
||||||
# (0,0,1) -> Discharge for local load only
|
# (0,0,1) -> Discharge for local load only
|
||||||
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
|
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
|
||||||
@@ -505,13 +533,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
if hour_idx < len(self.battery_grid_export_allowed)
|
if hour_idx < len(self.battery_grid_export_allowed)
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
|
# Solutions written before graded export carry no factor array; they
|
||||||
|
# exported at full power wherever the signal was set.
|
||||||
|
battery_grid_export_factor_hour = (
|
||||||
|
float(self.battery_grid_export_factor[hour_idx])
|
||||||
|
if hour_idx < len(self.battery_grid_export_factor)
|
||||||
|
else (1.0 if battery_grid_export_allowed_hour else 0.0)
|
||||||
|
)
|
||||||
|
|
||||||
# Raw genetic gene values — optimizer intent, stored verbatim
|
# Raw genetic gene values — optimizer intent, stored verbatim
|
||||||
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
|
operation["genetic_ac_charge_factor"].append(ac_charge_hour)
|
||||||
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
|
operation["genetic_dc_charge_factor"].append(dc_charge_hour)
|
||||||
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
|
operation["genetic_discharge_allowed_factor"].append(float(discharge_allowed_hour))
|
||||||
operation["genetic_battery_grid_export_allowed_factor"].append(
|
operation["genetic_battery_grid_export_allowed_factor"].append(
|
||||||
float(battery_grid_export_allowed_hour)
|
battery_grid_export_factor_hour
|
||||||
)
|
)
|
||||||
|
|
||||||
# SOC-clamped effective values — what can physically be executed at
|
# SOC-clamped effective values — what can physically be executed at
|
||||||
@@ -530,7 +565,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
|
|||||||
battery_grid_export_allowed_hour,
|
battery_grid_export_allowed_hour,
|
||||||
)
|
)
|
||||||
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
operation_mode, operation_mode_factor = self._battery_operation_from_solution(
|
||||||
eff_ac, eff_dc, eff_dis, eff_grid_export
|
eff_ac, eff_dc, eff_dis, eff_grid_export, battery_grid_export_factor_hour
|
||||||
)
|
)
|
||||||
for mode in BatteryOperationMode:
|
for mode in BatteryOperationMode:
|
||||||
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
|
mode_key = f"{battery_device_id}_{mode.lower()}_op_mode"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from akkudoktoreos.devices.devices import BatteriesCommonSettings
|
||||||
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
from akkudoktoreos.devices.genetic.battery import Battery, SolarPanelBatteryParameters
|
||||||
|
|
||||||
|
|
||||||
@@ -343,3 +345,41 @@ def test_quarter_hour_discharge_calls_share_one_power_budget():
|
|||||||
battery.reset()
|
battery.reset()
|
||||||
|
|
||||||
assert battery.discharged_energy_wh(0) == 0.0
|
assert battery.discharged_energy_wh(0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_export_rates_are_sorted_and_deduplicated():
|
||||||
|
"""Export rates are normalized like the charge rates."""
|
||||||
|
settings = BatteriesCommonSettings(
|
||||||
|
device_id="battery1", grid_export_rates=[1.0, 0.5, 0.5, 0.25]
|
||||||
|
)
|
||||||
|
assert list(settings.grid_export_rates) == [0.25, 0.5, 1.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_export_rates_default_and_override():
|
||||||
|
"""None falls back to the defaults; [1.0] restores all-or-nothing export."""
|
||||||
|
assert list(BatteriesCommonSettings(device_id="battery1").grid_export_rates) == [
|
||||||
|
0.25,
|
||||||
|
0.5,
|
||||||
|
0.75,
|
||||||
|
1.0,
|
||||||
|
]
|
||||||
|
assert list(
|
||||||
|
BatteriesCommonSettings(device_id="battery1", grid_export_rates=None).grid_export_rates
|
||||||
|
) == [0.25, 0.5, 0.75, 1.0]
|
||||||
|
assert list(
|
||||||
|
BatteriesCommonSettings(device_id="battery1", grid_export_rates=[1.0]).grid_export_rates
|
||||||
|
) == [1.0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rates", [[0.0, 0.5], [1.5], [-0.25], []])
|
||||||
|
def test_grid_export_rates_reject_invalid_values(rates):
|
||||||
|
"""0.0 is not an export level, and rates above the rated power are rejected."""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
BatteriesCommonSettings(device_id="battery1", grid_export_rates=rates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rated_discharge_energy_scales_with_slot_duration(setup_pv_battery):
|
||||||
|
"""The rate reference is the rated discharge energy of one slot."""
|
||||||
|
battery = setup_pv_battery
|
||||||
|
expected = battery.max_charge_power_w * battery.slot_duration_h * battery.discharging_efficiency
|
||||||
|
assert battery.rated_discharge_energy_wh() == pytest.approx(expected)
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ def test_direct_marketing_uses_market_price_as_feed_in_tariff(config_eos: Config
|
|||||||
"gesamtlast": [0.0, 0.0],
|
"gesamtlast": [0.0, 0.0],
|
||||||
},
|
},
|
||||||
pv_akku=None,
|
pv_akku=None,
|
||||||
inverter=None,
|
# Without an inverter the simulation books no grid energy at all, so the
|
||||||
|
# price signal would never reach the fitness.
|
||||||
|
inverter={"device_id": "inverter1", "max_power_wh": 20000},
|
||||||
eauto=None,
|
eauto=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +88,63 @@ def test_direct_marketing_keeps_variable_feed_in_tariff(config_eos: ConfigEOS):
|
|||||||
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
|
assert adjusted.ems.einspeiseverguetung_euro_pro_wh == [0.0001, -0.00005]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_export_rates_reach_the_solution(config_eos: ConfigEOS):
|
||||||
|
"""Configured export rates end up as per-slot export levels in the solution."""
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{
|
||||||
|
"prediction": {"hours": 24},
|
||||||
|
"optimization": {
|
||||||
|
"horizon_hours": 24,
|
||||||
|
"interval": 3600,
|
||||||
|
"genetic": {"individuals": 40, "generations": 10},
|
||||||
|
},
|
||||||
|
"feedintariff": {"direct_marketing_enabled": True},
|
||||||
|
"devices": {
|
||||||
|
"max_batteries": 1,
|
||||||
|
"batteries": [{"device_id": "battery1", "grid_export_rates": [0.5, 1.0]}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||||
|
CacheEnergyManagementStore().clear()
|
||||||
|
|
||||||
|
hours = 24
|
||||||
|
parameters = GeneticOptimizationParameters(
|
||||||
|
ems={
|
||||||
|
"pv_prognose_wh": [0.0] * hours,
|
||||||
|
"strompreis_euro_pro_wh": [0.0003] * hours,
|
||||||
|
# A pronounced tariff peak makes exporting worthwhile at all.
|
||||||
|
"einspeiseverguetung_euro_pro_wh": [0.0001] * 12 + [0.0009] * 12,
|
||||||
|
"preis_euro_pro_wh_akku": 0.0,
|
||||||
|
"gesamtlast": [200.0] * hours,
|
||||||
|
},
|
||||||
|
pv_akku={
|
||||||
|
"device_id": "battery1",
|
||||||
|
"capacity_wh": 10000,
|
||||||
|
"initial_soc_percentage": 100,
|
||||||
|
"min_soc_percentage": 0,
|
||||||
|
"max_charge_power_w": 5000,
|
||||||
|
},
|
||||||
|
inverter={
|
||||||
|
"device_id": "inverter1",
|
||||||
|
"max_power_wh": 10000,
|
||||||
|
"battery_id": "battery1",
|
||||||
|
},
|
||||||
|
eauto=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
optimization = GeneticOptimization(fixed_seed=42)
|
||||||
|
solution = optimization.optimierung_ems(parameters=parameters, start_hour=0, ngen=3)
|
||||||
|
|
||||||
|
# Full power first, so the full-power state keeps the lowest export index.
|
||||||
|
assert optimization.bat_possible_grid_export_values == [1.0, 0.5]
|
||||||
|
assert len(solution.battery_grid_export_factor) == len(solution.battery_grid_export_allowed)
|
||||||
|
assert set(solution.battery_grid_export_factor) <= {0.0, 0.5, 1.0}
|
||||||
|
assert [
|
||||||
|
1 if factor > 0.0 else 0 for factor in solution.battery_grid_export_factor
|
||||||
|
] == solution.battery_grid_export_allowed
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"fn_in, fn_out, ngen, break_even",
|
"fn_in, fn_out, ngen, break_even",
|
||||||
[
|
[
|
||||||
@@ -201,3 +260,122 @@ def test_optimize(
|
|||||||
# Check the correct generic energy management plan is created
|
# Check the correct generic energy management plan is created
|
||||||
plan = genetic_solution.energy_management_plan()
|
plan = genetic_solution.energy_management_plan()
|
||||||
# @TODO
|
# @TODO
|
||||||
|
|
||||||
|
|
||||||
|
def _ev_deadline_parameters(hours: int, **ev_extra) -> GeneticOptimizationParameters:
|
||||||
|
"""Optimization parameters with an EV that has to be charged."""
|
||||||
|
return GeneticOptimizationParameters(
|
||||||
|
ems={
|
||||||
|
"pv_prognose_wh": [0.0] * hours,
|
||||||
|
# Expensive for the first six hours, dirt cheap afterwards: without a
|
||||||
|
# deadline the optimizer would always wait for the cheap slots.
|
||||||
|
"strompreis_euro_pro_wh": [0.0009] * 6 + [0.00001] * (hours - 6),
|
||||||
|
"einspeiseverguetung_euro_pro_wh": [0.00007] * hours,
|
||||||
|
"preis_euro_pro_wh_akku": 0.0,
|
||||||
|
"gesamtlast": [300.0] * hours,
|
||||||
|
},
|
||||||
|
pv_akku=None,
|
||||||
|
inverter=None,
|
||||||
|
eauto={
|
||||||
|
"device_id": "ev1",
|
||||||
|
"capacity_wh": 60000,
|
||||||
|
"charging_efficiency": 0.95,
|
||||||
|
"max_charge_power_w": 11040,
|
||||||
|
"initial_soc_percentage": 20,
|
||||||
|
"min_soc_percentage": 60,
|
||||||
|
**ev_extra,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ev_deadline_slot_resolution(config_eos: ConfigEOS):
|
||||||
|
"""Datetime and maximum duration resolve to a slot; the earlier one wins."""
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}}
|
||||||
|
)
|
||||||
|
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||||
|
optimization = GeneticOptimization(fixed_seed=1)
|
||||||
|
optimization._slot0_datetime = optimization.ems.start_datetime.set(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
slot0 = optimization._slot0_datetime
|
||||||
|
|
||||||
|
# Duration only: 6 h after the start hour 10.
|
||||||
|
parameters = _ev_deadline_parameters(48, min_soc_max_duration_h=6)
|
||||||
|
assert optimization._ev_deadline_slot(parameters) == 16
|
||||||
|
|
||||||
|
# Datetime only.
|
||||||
|
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=14))
|
||||||
|
assert optimization._ev_deadline_slot(parameters) == 14
|
||||||
|
|
||||||
|
# Both: the earlier one wins.
|
||||||
|
parameters = _ev_deadline_parameters(
|
||||||
|
48, min_soc_deadline_datetime=slot0.add(hours=20), min_soc_max_duration_h=6
|
||||||
|
)
|
||||||
|
assert optimization._ev_deadline_slot(parameters) == 16
|
||||||
|
|
||||||
|
# Beyond the horizon: no deadline, the end-of-horizon target already covers it.
|
||||||
|
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=100))
|
||||||
|
assert optimization._ev_deadline_slot(parameters) is None
|
||||||
|
|
||||||
|
# In the past: due right now.
|
||||||
|
parameters = _ev_deadline_parameters(48, min_soc_deadline_datetime=slot0.add(hours=2))
|
||||||
|
assert optimization._ev_deadline_slot(parameters) == optimization._start_day_slot()
|
||||||
|
|
||||||
|
# No deadline at all.
|
||||||
|
assert optimization._ev_deadline_slot(_ev_deadline_parameters(48)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ev_soc_penalty_reads_the_deadline_slot(config_eos: ConfigEOS):
|
||||||
|
"""With a deadline the penalty checks the SoC at that slot, not at the end."""
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{"prediction": {"hours": 48}, "optimization": {"horizon_hours": 48, "interval": 3600}}
|
||||||
|
)
|
||||||
|
ems_eos.set_start_datetime(to_datetime().set(hour=10, minute=0))
|
||||||
|
optimization = GeneticOptimization(fixed_seed=1)
|
||||||
|
simulation_result = {"EAuto_SoC_pro_Stunde": [20.0, 35.0, 50.0, 80.0]}
|
||||||
|
|
||||||
|
class _Ev:
|
||||||
|
def current_soc_percentage(self):
|
||||||
|
return 80.0
|
||||||
|
|
||||||
|
optimization.simulation.ev = _Ev()
|
||||||
|
|
||||||
|
# Without a deadline the final SoC counts.
|
||||||
|
optimization._ev_soc_deadline_slot = None
|
||||||
|
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||||
|
|
||||||
|
# With one, the SoC at the beginning of the deadline slot counts.
|
||||||
|
optimization._ev_soc_deadline_slot = 12
|
||||||
|
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 50.0
|
||||||
|
|
||||||
|
# A deadline beyond the reported slots falls back to the final SoC.
|
||||||
|
optimization._ev_soc_deadline_slot = 99
|
||||||
|
assert optimization._ev_soc_at_deadline(simulation_result, 10) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ev_deadline_charges_before_departure(config_eos: ConfigEOS):
|
||||||
|
"""The EV reaches its target before the deadline even when energy is cheaper later."""
|
||||||
|
hours = 24
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{
|
||||||
|
"prediction": {"hours": hours},
|
||||||
|
"optimization": {
|
||||||
|
"horizon_hours": hours,
|
||||||
|
"interval": 3600,
|
||||||
|
"genetic": {"individuals": 100, "generations": 40},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||||
|
CacheEnergyManagementStore().clear()
|
||||||
|
|
||||||
|
parameters = _ev_deadline_parameters(hours, min_soc_max_duration_h=6)
|
||||||
|
solution = GeneticOptimization(fixed_seed=42).optimierung_ems(
|
||||||
|
parameters=parameters, start_hour=0, ngen=40
|
||||||
|
)
|
||||||
|
|
||||||
|
soc_per_hour = solution.result.EAuto_SoC_pro_Stunde
|
||||||
|
# Slot 6 is the first slot at or after the deadline, so its start-of-slot SoC
|
||||||
|
# is what the target is checked against.
|
||||||
|
assert soc_per_hour[6] >= 60.0
|
||||||
|
|||||||
@@ -528,6 +528,20 @@ def test_direct_marketing_battery_grid_export_uses_separate_signal(config_eos):
|
|||||||
assert simulation.battery.current_soc_percentage() == 50.0
|
assert simulation.battery.current_soc_percentage() == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_marketing_grid_export_rate_limits_exported_energy(config_eos):
|
||||||
|
"""A partial export level exports that share of the rated discharge power."""
|
||||||
|
simulation = _direct_marketing_battery_export_simulation(config_eos)
|
||||||
|
assert simulation.bat_grid_export_hours is not None
|
||||||
|
# 500 W rated discharge power over a one hour slot -> 500 Wh at rate 1.0.
|
||||||
|
simulation.bat_grid_export_hours[0] = 0.5
|
||||||
|
|
||||||
|
result = simulation.simulate(start_hour=0)
|
||||||
|
|
||||||
|
assert result["Netzeinspeisung_Wh_pro_Stunde"][0] == pytest.approx(250.0)
|
||||||
|
assert simulation.battery is not None
|
||||||
|
assert simulation.battery.current_soc_percentage() == 75.0
|
||||||
|
|
||||||
|
|
||||||
def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos):
|
def test_battery_lcos_is_charged_once_on_delivered_energy(config_eos):
|
||||||
simulation = _direct_marketing_battery_export_simulation(
|
simulation = _direct_marketing_battery_export_simulation(
|
||||||
config_eos,
|
config_eos,
|
||||||
|
|||||||
@@ -74,3 +74,55 @@ def test_decode_charge_discharge_has_self_consumption_state_after_legacy_export(
|
|||||||
assert dc_charge.tolist() == [1]
|
assert dc_charge.tolist() == [1]
|
||||||
assert discharge.tolist() == [1]
|
assert discharge.tolist() == [1]
|
||||||
assert battery_grid_export.tolist() == [0]
|
assert battery_grid_export.tolist() == [0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_graded_grid_export_states_decode_to_rates():
|
||||||
|
"""Each configured export rate gets its own state; state 5 stays full power."""
|
||||||
|
optimization = GeneticOptimization()
|
||||||
|
optimization.bat_possible_charge_values = [1.0]
|
||||||
|
optimization.bat_possible_grid_export_values = [1.0, 0.5, 0.25]
|
||||||
|
optimization.optimize_dc_charge = True
|
||||||
|
optimization.optimize_battery_grid_export = True
|
||||||
|
|
||||||
|
layout = optimization._battery_state_layout()
|
||||||
|
|
||||||
|
assert layout.grid_export_states == (5, 6, 7)
|
||||||
|
# The full-power state keeps its index, so existing seeds stay valid.
|
||||||
|
assert layout.grid_export_state == 5
|
||||||
|
assert layout.self_consumption_state == 8
|
||||||
|
assert layout.total_states == 9
|
||||||
|
|
||||||
|
_, _, _, battery_grid_export = optimization.decode_charge_discharge(
|
||||||
|
np.array([0, 5, 6, 7])
|
||||||
|
)
|
||||||
|
assert battery_grid_export.tolist() == [0.0, 1.0, 0.5, 0.25]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_export_rate_keeps_all_or_nothing_layout():
|
||||||
|
"""Without configured rates the state space is the one from before grading."""
|
||||||
|
optimization = GeneticOptimization()
|
||||||
|
optimization.bat_possible_charge_values = [1.0]
|
||||||
|
optimization.optimize_dc_charge = True
|
||||||
|
optimization.optimize_battery_grid_export = True
|
||||||
|
|
||||||
|
layout = optimization._battery_state_layout()
|
||||||
|
|
||||||
|
assert layout.grid_export_states == (5,)
|
||||||
|
assert layout.total_states == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_battery_grid_export_factor_becomes_operation_factor(config_eos):
|
||||||
|
"""A partial export level is reported as the GRID_SUPPORT_EXPORT factor."""
|
||||||
|
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||||||
|
solution = GeneticSolution.model_construct()
|
||||||
|
|
||||||
|
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||||||
|
ac_charge=0.0,
|
||||||
|
dc_charge=0.0,
|
||||||
|
discharge_allowed=False,
|
||||||
|
battery_grid_export_allowed=True,
|
||||||
|
battery_grid_export_factor=0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||||||
|
assert operation_mode_factor == 0.25
|
||||||
|
|||||||
@@ -353,3 +353,188 @@ def test_start_solution_layout_mismatch_is_ignored(config_eos):
|
|||||||
assert opt._start_solution_matches_layout(bad_solution) is False
|
assert opt._start_solution_matches_layout(bad_solution) is False
|
||||||
good_solution = [0] * opt.total_slots + [0]
|
good_solution = [0] * opt.total_slots + [0]
|
||||||
assert opt._start_solution_matches_layout(good_solution) is True
|
assert opt._start_solution_matches_layout(good_solution) is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Absolute bounds: earliest start and deadline
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_deadline_limits_starts_to_completed_runs():
|
||||||
|
"""A run has to be finished at (not just started before) the deadline."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=2000,
|
||||||
|
duration_h=2,
|
||||||
|
deadline_datetime=slot0.add(hours=10),
|
||||||
|
)
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
|
||||||
|
)
|
||||||
|
# 2 h run, deadline 10:00 -> last start 08:00
|
||||||
|
assert allowed == list(range(0, 9))
|
||||||
|
assert appliance.deadline_relaxed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_on_quarter_hour_grid():
|
||||||
|
"""Deadlines are honoured slot-exact on a sub-hourly grid."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48 * 4,
|
||||||
|
0.25,
|
||||||
|
device_id="d",
|
||||||
|
load_profile_power_w=[1000.0, 1000.0, 1000.0],
|
||||||
|
load_profile_interval_seconds=900,
|
||||||
|
deadline_datetime=slot0.add(hours=3),
|
||||||
|
)
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48 * 4
|
||||||
|
)
|
||||||
|
# 45 min run, deadline 03:00 (slot 12) -> last start slot 9 (02:15-03:00)
|
||||||
|
assert allowed[-1] == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_earliest_start_datetime_limits_starts():
|
||||||
|
"""An absolute earliest start pushes the first allowed slot back."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=1000,
|
||||||
|
duration_h=1,
|
||||||
|
earliest_start_datetime=slot0.add(hours=20),
|
||||||
|
deadline_datetime=slot0.add(hours=27),
|
||||||
|
)
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=slot0, earliest_slot=0, horizon_end_slot=48
|
||||||
|
)
|
||||||
|
assert allowed == list(range(20, 27))
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_best_effort_runs_as_early_as_possible():
|
||||||
|
"""An unreachable BEST_EFFORT deadline schedules the run with minimal delay."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=2000,
|
||||||
|
duration_h=2,
|
||||||
|
# "now" is 12:00, so a 10:00 deadline can not be met any more.
|
||||||
|
deadline_datetime=slot0.add(hours=10),
|
||||||
|
)
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=slot0, earliest_slot=12, horizon_end_slot=48
|
||||||
|
)
|
||||||
|
# Deadline already missed -> the only offered start is the earliest one.
|
||||||
|
assert allowed == [12]
|
||||||
|
assert appliance.deadline_relaxed is True
|
||||||
|
assert appliance.deadline_missed([12], slot0) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_strict_keeps_empty_result():
|
||||||
|
"""A STRICT deadline that can not be met yields no allowed start."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=2000,
|
||||||
|
duration_h=2,
|
||||||
|
deadline_datetime=slot0.add(hours=10),
|
||||||
|
deadline_policy="STRICT",
|
||||||
|
)
|
||||||
|
allowed = appliance.allowed_start_slots(
|
||||||
|
slot0_datetime=slot0, earliest_slot=12, horizon_end_slot=48
|
||||||
|
)
|
||||||
|
assert allowed == []
|
||||||
|
assert appliance.deadline_relaxed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_strict_once_raises(config_eos):
|
||||||
|
"""A ONCE consumer with an unreachable STRICT deadline fails the layout."""
|
||||||
|
opt = _optimizer(config_eos, prediction_hours=48, horizon_hours=48, interval=3600, hour=12)
|
||||||
|
slot0 = opt.ems.start_datetime.set(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=2000,
|
||||||
|
duration_h=2,
|
||||||
|
deadline_datetime=slot0.add(hours=10),
|
||||||
|
deadline_policy="STRICT",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="no valid start"):
|
||||||
|
opt._build_appliance_layout([appliance], slot0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_scheduled_run_reported_as_kept(config_eos):
|
||||||
|
"""A met deadline is reported as not missed."""
|
||||||
|
slot0 = to_datetime("2026-07-15 00:00:00")
|
||||||
|
appliance = _appliance(
|
||||||
|
48,
|
||||||
|
1.0,
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=1000,
|
||||||
|
duration_h=1,
|
||||||
|
deadline_datetime=slot0.add(hours=10),
|
||||||
|
)
|
||||||
|
assert appliance.deadline_missed([9], slot0) is False
|
||||||
|
assert appliance.deadline_missed([], slot0) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_before_earliest_start_rejected():
|
||||||
|
with pytest.raises(ValidationError, match="must be after"):
|
||||||
|
HomeApplianceParameters(
|
||||||
|
device_id="d",
|
||||||
|
consumption_wh=1000,
|
||||||
|
duration_h=1,
|
||||||
|
earliest_start_datetime="2026-07-15 20:00:00",
|
||||||
|
deadline_datetime="2026-07-15 18:00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deadline_end_to_end_optimization(config_eos):
|
||||||
|
"""The optimizer only picks starts whose run finishes before the deadline."""
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{
|
||||||
|
"prediction": {"hours": 48},
|
||||||
|
"optimization": {
|
||||||
|
"horizon_hours": 48,
|
||||||
|
"interval": 3600,
|
||||||
|
"genetic": {
|
||||||
|
"individuals": 60,
|
||||||
|
"generations": 10,
|
||||||
|
"penalties": {"ev_soc_miss": 10, "ac_charge_break_even": 0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ems_eos.set_start_datetime(to_datetime().set(hour=0, minute=0))
|
||||||
|
CacheEnergyManagementStore().clear()
|
||||||
|
deadline = ems_eos.start_datetime.set(hour=0, minute=0).add(hours=8)
|
||||||
|
parameters = GeneticOptimizationParameters(
|
||||||
|
ems=_ems(48),
|
||||||
|
pv_akku=None,
|
||||||
|
inverter=None,
|
||||||
|
eauto=None,
|
||||||
|
home_appliances=[
|
||||||
|
HomeApplianceParameters(
|
||||||
|
device_id="dw",
|
||||||
|
consumption_wh=1000,
|
||||||
|
duration_h=2,
|
||||||
|
deadline_datetime=deadline,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
solution = GeneticOptimization(fixed_seed=7).optimierung_ems(
|
||||||
|
parameters=parameters, start_hour=0, ngen=3
|
||||||
|
)
|
||||||
|
|
||||||
|
starts = solution.appliance_starts["dw"]
|
||||||
|
assert len(starts) == 1
|
||||||
|
# 2 h run has to be complete at the deadline.
|
||||||
|
assert starts[0].add(hours=2) <= deadline
|
||||||
|
assert solution.appliance_deadline_missed == {"dw": False}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ def mock_battery() -> Mock:
|
|||||||
mock_battery = Mock()
|
mock_battery = Mock()
|
||||||
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
|
mock_battery.charge_energy = Mock(return_value=(0.0, 0.0))
|
||||||
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
|
mock_battery.discharge_energy = Mock(return_value=(0.0, 0.0))
|
||||||
|
# Rated discharge energy of one slot - the reference a grid-export rate is
|
||||||
|
# applied to. Large enough to never bind at the default factor of 1.0.
|
||||||
|
mock_battery.rated_discharge_energy_wh = Mock(return_value=1e9)
|
||||||
mock_battery.parameters.device_id = "battery1"
|
mock_battery.parameters.device_id = "battery1"
|
||||||
return mock_battery
|
return mock_battery
|
||||||
|
|
||||||
@@ -243,6 +246,26 @@ def test_process_energy_allows_battery_grid_export(inverter, mock_battery):
|
|||||||
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
inverter.self_consumption_predictor.calculate_expected_direct_consumption.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_energy_grid_export_rate_limits_export(inverter, mock_battery):
|
||||||
|
"""An export rate caps the export at that share of the rated discharge power."""
|
||||||
|
mock_battery.max_charge_power_w = 300.0
|
||||||
|
mock_battery.remaining_discharge_energy_wh.return_value = 200.0
|
||||||
|
mock_battery.rated_discharge_energy_wh.return_value = 300.0
|
||||||
|
mock_battery.discharge_energy.side_effect = [(100.0, 0.0), (150.0, 0.0)]
|
||||||
|
|
||||||
|
grid_export, grid_import, losses, self_consumption = inverter.process_energy(
|
||||||
|
generation=0.0,
|
||||||
|
consumption=100.0,
|
||||||
|
hour=12,
|
||||||
|
allow_battery_grid_export=True,
|
||||||
|
battery_grid_export_factor=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 0.5 * 300 Wh rated = 150 Wh, below the 200 Wh the battery could still give.
|
||||||
|
assert grid_export == pytest.approx(150.0)
|
||||||
|
mock_battery.discharge_energy.assert_has_calls([call(100.0, 12), call(150.0, 12)])
|
||||||
|
|
||||||
|
|
||||||
def test_process_energy_battery_empty(inverter, mock_battery):
|
def test_process_energy_battery_empty(inverter, mock_battery):
|
||||||
# Battery is empty, so no energy can be discharged
|
# Battery is empty, so no energy can be discharged
|
||||||
mock_battery.discharge_energy.return_value = (0.0, 0.0)
|
mock_battery.discharge_energy.return_value = (0.0, 0.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user