mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
189 lines
6.9 KiB
Python
189 lines
6.9 KiB
Python
# ruff: noqa: S101
|
|||
|
|
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
from akkudoktoreos.devices.devicesabc import BatteryOperationMode
|
||
|
|
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||
|
|
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
||
|
|
|
||
|
|
|
||
|
|
def test_battery_discharge_allowed_remains_local_load_mode(config_eos):
|
||
|
|
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||
|
|
solution = GeneticSolution
|
||
|
|
|
||
|
|
operation_mode, operation_mode_factor = solution._battery_operation_from_solution(
|
||
|
|
ac_charge=0.0,
|
||
|
|
dc_charge=0.0,
|
||
|
|
discharge_allowed=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert operation_mode == BatteryOperationMode.PEAK_SHAVING
|
||
|
|
assert operation_mode_factor == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_battery_grid_export_signal_maps_to_grid_support_export(config_eos):
|
||
|
|
config_eos.merge_settings_from_dict({"feedintariff": {"direct_marketing_enabled": True}})
|
||
|
|
solution = GeneticSolution
|
||
|
|
|
||
|
|
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,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert operation_mode == BatteryOperationMode.GRID_SUPPORT_EXPORT
|
||
|
|
assert operation_mode_factor == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_decode_charge_discharge_has_separate_battery_grid_export_state():
|
||
|
|
optimization = GeneticOptimization()
|
||
|
|
optimization.bat_possible_charge_values = [1.0]
|
||
|
|
optimization.optimize_dc_charge = True
|
||
|
|
optimization.optimize_battery_grid_export = True
|
||
|
|
|
||
|
|
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||
|
|
np.array([5])
|
||
|
|
)
|
||
|
|
|
||
|
|
assert ac_charge.tolist() == [0.0]
|
||
|
|
assert dc_charge.tolist() == [0]
|
||
|
|
assert discharge.tolist() == [0]
|
||
|
|
assert battery_grid_export.tolist() == [1]
|
||
|
|
|
||
|
|
|
||
|
|
def test_decode_charge_discharge_has_self_consumption_state_after_legacy_export():
|
||
|
|
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()
|
||
|
|
ac_charge, dc_charge, discharge, battery_grid_export = optimization.decode_charge_discharge(
|
||
|
|
np.array([6])
|
||
|
|
)
|
||
|
|
|
||
|
|
assert layout.total_states == 7
|
||
|
|
assert layout.grid_export_state == 5
|
||
|
|
assert layout.self_consumption_state == 6
|
||
|
|
assert ac_charge.tolist() == [0.0]
|
||
|
|
assert dc_charge.tolist() == [1]
|
||
|
|
assert discharge.tolist() == [1]
|
||
|
|
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
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
def test_disjoint_cycle_masks_keep_feasible_non_deadline_order(config_eos):
|
||
|
|
from akkudoktoreos.core.coreabc import get_ems
|
||
|
|
from akkudoktoreos.optimization.genetic.geneticparams import (
|
||
|
|
GeneticOptimizationParameters,
|
||
|
|
)
|
||
|
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||
|
|
|
||
|
|
config_eos.merge_settings_from_dict(
|
||
|
|
{
|
||
|
|
"optimization": {
|
||
|
|
"genetic": {
|
||
|
|
"interval_sec": 900,
|
||
|
|
"horizon_hours": 2,
|
||
|
|
"tail_horizon_hours": 0,
|
||
|
|
"terminal_value_mode": "FIXED",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
)
|
||
|
|
get_ems(init=True).set_start_datetime(
|
||
|
|
to_datetime("2026-09-16T00:00:00+02:00", in_timezone="Europe/Berlin")
|
||
|
|
)
|
||
|
|
params = GeneticOptimizationParameters.model_validate(
|
||
|
|
{
|
||
|
|
"ems": {
|
||
|
|
"pv_forecast_wh": [0.0] * 8,
|
||
|
|
"total_load": [0.0] * 8,
|
||
|
|
"electricity_price_per_wh": [0.0003] * 8,
|
||
|
|
"feed_in_tariff_per_wh": 0.0,
|
||
|
|
"price_per_wh_battery": 0.0,
|
||
|
|
},
|
||
|
|
"forecast_interval_seconds": 900,
|
||
|
|
"pv_battery": None,
|
||
|
|
"ev": None,
|
||
|
|
"inverter": {"device_id": "inv", "max_power_wh": 1000},
|
||
|
|
"home_appliances": [
|
||
|
|
{
|
||
|
|
"device_id": "washer",
|
||
|
|
"num_cycles": 2,
|
||
|
|
"load_profile_power_w": [1000.0, 1000.0],
|
||
|
|
"load_profile_interval_seconds": 900,
|
||
|
|
"time_windows": {
|
||
|
|
"windows": [
|
||
|
|
{"start_time": "01:00", "duration": "30 minutes", "value": 0},
|
||
|
|
{"start_time": "00:00", "duration": "30 minutes", "value": 1},
|
||
|
|
{"start_time": "01:15", "duration": "30 minutes", "value": 1},
|
||
|
|
]
|
||
|
|
},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
)
|
||
|
|
optimizer = GeneticOptimization(fixed_seed=42)
|
||
|
|
solution = optimizer.optimize_ems(params, ngen=1, individuals=6)
|
||
|
|
assert [(item.hour, item.minute) for item in solution.appliance_starts["washer"]] == [
|
||
|
|
(0, 0),
|
||
|
|
(1, 0),
|
||
|
|
]
|
||
|
|
assert sum(solution.result.home_appliance_energy_wh["washer"]) == 1000.0
|
||
|
|
# Even a candidate choosing the second disconnected window repairs to the
|
||
|
|
# feasible order, without dropping a configured run or crossing its mask.
|
||
|
|
genes = [0, 1]
|
||
|
|
assert optimizer._decode_appliance_starts(genes) == {0: [0, 4]}
|
||
|
|
assert genes == [0, 0]
|