feat(optimization): model battery LCOS and probabilistic bypass

This commit is contained in:
Andreas
2026-07-15 09:23:29 +02:00
parent 8ddb7ce754
commit bed1f0f275
27 changed files with 1362 additions and 890 deletions
+1 -1
View File
@@ -456,7 +456,7 @@ as a cohesive unit for scheduling and availability checking.
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [€/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_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
+1 -1
View File
@@ -99,7 +99,7 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| access_token | `Optional[str]` | `rw` | `None` | Tibber API access token. |
| home_id | `Optional[str]` | `rw` | `None` | Tibber home id to read prices from. |
| home_id | `Optional[str]` | `rw` | `None` | Optional Tibber home id. If omitted, the first home with a subscription is used. |
:::
<!-- pyml enable line-length -->
+2
View File
@@ -176,6 +176,8 @@
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"visualize_pdf": true,
"terminal_value_euro_per_kwh": 0.0,
"genetic": {
"individuals": 400,
"generations": 400,
+6
View File
@@ -13,6 +13,8 @@
| horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
| interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval (slot length) [sec]. The genetic optimizer supports 3600 (1 hour) and 900 (15 min); other values fall back to 3600. Defaults to 3600 seconds (1 hour). |
| keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. |
| terminal_value_euro_per_kwh | `EOS_OPTIMIZATION__TERMINAL_VALUE_EURO_PER_KWH` | `float` | `rw` | `0.0` | Value assigned to usable battery energy remaining at the end of the optimization horizon [EUR/kWh]. This terminal value is independent of the battery LCOS. Defaults to 0 EUR/kWh. |
| visualize_pdf | `EOS_OPTIMIZATION__VISUALIZE_PDF` | `bool` | `rw` | `True` | Generate the PDF visualization after each optimization run. Disable for headless setups (e.g. Node-RED integration) to save several seconds per run. Defaults to True. |
:::
<!-- pyml enable line-length -->
@@ -27,6 +29,8 @@
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"visualize_pdf": true,
"terminal_value_euro_per_kwh": 0.0,
"genetic": {
"individuals": 400,
"generations": 400,
@@ -51,6 +55,8 @@
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"visualize_pdf": true,
"terminal_value_euro_per_kwh": 0.0,
"genetic": {
"individuals": 400,
"generations": 400,
+20 -1
View File
@@ -145,6 +145,11 @@ The energy management can be run in three modes:
`prediction.hours * (3600 / interval)`, and device power caps as well as the solution
and energy-management-plan serializers are slot-aware.
- **terminal_value_euro_per_kwh** (`float`, default: `0.0`): Monetary value assigned to usable
battery energy remaining at the end of the optimization horizon. This terminal value influences
whether the optimizer preserves or depletes the battery near the horizon. It is independent of
the battery's `levelized_cost_of_storage_kwh`, which prices actual discharge throughput.
:::{note}
Use `900` together with a 15-minute electricity price source (for example a dynamic or
exchange-priced tariff) to let the optimizer schedule on a quarter-hour grid. Keeping the
@@ -224,8 +229,9 @@ The behavior of the genetic algorithm can be customized using the following conf
```json
{
"optimization": {
"hours": 24,
"horizon_hours": 24,
"interval": 3600,
"terminal_value_euro_per_kwh": 0.20,
"genetic" : {
"individuals": 300,
"generations": 400,
@@ -342,6 +348,19 @@ The inverter supports separate AC↔DC conversion efficiencies:
}
```
`levelized_cost_of_storage_kwh` is an optional variable cost in EUR/kWh (default `0.0`). EOS applies
it exactly once to the DC energy actually delivered by the battery, whether that energy supplies
the local load or is exported to the grid:
```{math}
C_{LCOS} = \frac{E_{bat,out,Wh}}{1000}\,c_{LCOS,EUR/kWh}
```
Charging energy, battery-internal discharge losses, and the subsequent DC-to-AC inverter loss do
not receive another LCOS charge. LCOS is included in the hourly and total simulation costs and is
separate from `optimization.terminal_value_euro_per_kwh`, which values only the usable energy left
at the end of the optimization horizon.
#### Home appliance simulation configuration
**Example:**
+71 -3
View File
@@ -70,6 +70,7 @@ to `DISABLED` in the configuration.
"pv_akku": {
"device_id": "battery1",
"capacity_wh": 26400,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000,
"initial_soc_percentage": 80,
"min_soc_percentage": 15
@@ -112,12 +113,15 @@ to `DISABLED` in the configuration.
### Energy Management System (EMS)
#### Battery Cost (`preis_euro_pro_wh_akku`)
#### Battery Terminal Value (`preis_euro_pro_wh_akku`)
- Unit: €/Wh
- Purpose: Represents the residual value of energy stored in the battery
- Impact: Lower values encourage battery depletion, higher values preserve charge at the end of the
simulation.
- Separation from LCOS: This value is only applied to usable battery energy remaining at the end of
the optimization horizon. Battery discharge throughput is priced separately with
`pv_akku.levelized_cost_of_storage_kwh`.
#### Feed-in Tariff (`einspeiseverguetung_euro_pro_wh`)
@@ -145,6 +149,51 @@ to `DISABLED` in the configuration.
- Format: Array of hourly values
- Data Source: `GET /v1/prediction/series?key=pvforecast_ac_power`
#### Probabilistic Direct PV Consumption and Bypass
Hourly or 15-minute mean values alone would optimistically assume that the smaller of mean PV
generation and mean load is consumed directly. Real household load varies within the interval. EOS
therefore uses a conditional probability table derived from one-minute load samples. For a forecast
mean load \(\mu_L\), the table contains load-bin powers \(L_i\) and their conditional probabilities
\(p_i = P(L=L_i\mid\mu_L)\), with \(\sum_i p_i=1\).
Because the finite 50 W table grid can deviate slightly from the requested forecast mean, the load
bins are first normalized without changing the shape of the distribution:
```{math}
\widetilde{L}_i = L_i \frac{\mu_L}{\sum_j p_j L_j}
```
For mean PV power \(P_{PV}\), the expected power flowing directly from PV to the load is:
```{math}
P_{direct} = \sum_i p_i \min\left(\widetilde{L}_i, P_{PV}\right)
```
For a slot of duration \(\Delta t\), EOS converts this power into energy and derives both residual
flows from the same direct-consumption value:
```{math}
\begin{aligned}
E_{direct} &= \Delta t\,P_{direct} \\
E_{load,residual} &= E_{load}-E_{direct} \\
E_{PV,surplus} &= E_{PV}-E_{direct}
\end{aligned}
```
The residual load is supplied by the battery and then the grid. The PV surplus charges the battery;
any remainder bypasses the battery and is exported. Both residual load and PV surplus may be
positive in the same coarse slot because they occur during different sub-intervals. This is expected
and preserves the energy balances
\(E_{direct}+E_{load,residual}=E_{load}\) and
\(E_{direct}+E_{PV,surplus}=E_{PV}\).
The bundled table is conditioned on a one-hour mean load and models load variation only; mean PV is
treated as constant inside the slot. For a 15-minute grid produced by splitting hourly energy, the
power lookup retains the original hourly mean. A native 15-minute load forecast uses the same table
as an approximation until a separately calibrated 15-minute distribution is available. Fast PV
variability, for example from clouds, is not represented by this table.
#### Electricity Price Forecast (`strompreis_euro_pro_wh`)
- Unit: €/Wh
@@ -162,8 +211,27 @@ Verify prices against your local tariffs.
- `capacity_wh`: Total battery capacity in Wh
- `charging_efficiency`: Charging efficiency (0-1)
- `discharging_efficiency`: Discharging efficiency (0-1)
- `levelized_cost_of_storage_kwh`: LCOS in EUR/kWh, charged once for every kWh of DC energy
delivered by the battery. Default: `0.0`.
- `max_charge_power_w`: Maximum charging power in W
#### Battery LCOS (`levelized_cost_of_storage_kwh`)
LCOS and terminal value have different purposes. LCOS is a variable battery-use cost and is added
once when the battery delivers energy, both for local load coverage and battery-to-grid export. It
is not charged when the battery is charged and is not charged again on battery-internal or
DC-to-AC inverter losses.
For battery-delivered DC energy `E_bat,out` in one slot:
```{math}
C_{LCOS} = \frac{E_{bat,out}}{1000}\,c_{LCOS}
```
where `E_bat,out` is in Wh and `c_LCOS` is in EUR/kWh. This cost is included in
`Kosten_Euro_pro_Stunde`, `Gesamtkosten_Euro`, and therefore `Gesamtbilanz_Euro`. The terminal value
`preis_euro_pro_wh_akku`, by contrast, applies only to usable energy remaining after the last slot.
#### State of Charge (SoC)
- `initial_soc_percentage`: Current battery level (%)
@@ -198,7 +266,7 @@ Round-trip efficiency for AC charging and discharging:
`η_round_trip = ac_to_dc_efficiency × charging_efficiency × discharging_efficiency × dc_to_ac_efficiency`
For profitability, the discharge electricity price must exceed:
`buy_price / η_round_trip`
`buy_price / η_round_trip + LCOS / dc_to_ac_efficiency`
**Backward compatibility**: With default values (`ac_to_dc_efficiency=1.0`,
`dc_to_ac_efficiency=1.0`, `max_ac_charge_power_w=null`), existing configurations work identically.
@@ -223,7 +291,7 @@ penalty = ac_wh_charged × (break_even_price best_uncovered_price) × factor
```
where:
- `break_even_price = charge_price / η_round_trip`
- `break_even_price = charge_price / η_round_trip + LCOS / dc_to_ac_efficiency`
- `best_uncovered_price` = highest future price not already covered by free PV battery energy
- `factor` = `optimization.genetic.penalties.ac_charge_break_even` (default `1.0`)