2025-10-28 02:50:31 +01:00
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from akkudoktoreos.core.emplan import (
|
|
|
|
|
BaseInstruction,
|
|
|
|
|
CommodityQuantity,
|
|
|
|
|
DDBCInstruction,
|
|
|
|
|
EnergyManagementPlan,
|
|
|
|
|
FRBCInstruction,
|
|
|
|
|
OMBCInstruction,
|
|
|
|
|
PEBCInstruction,
|
|
|
|
|
PEBCPowerEnvelope,
|
|
|
|
|
PEBCPowerEnvelopeElement,
|
|
|
|
|
PPBCEndInterruptionInstruction,
|
|
|
|
|
PPBCScheduleInstruction,
|
|
|
|
|
PPBCStartInterruptionInstruction,
|
|
|
|
|
)
|
|
|
|
|
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime, to_duration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def fixed_now():
|
|
|
|
|
return to_datetime("2025-06-01T12:00:00")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEnergyManagementPlan:
|
2025-12-30 22:08:21 +01:00
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# Helpers (only used inside the class)
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
def _make_instr(self, resource_id, execution_time, duration=None):
|
|
|
|
|
if duration is None:
|
|
|
|
|
instr = OMBCInstruction(
|
|
|
|
|
id=resource_id,
|
|
|
|
|
execution_time=execution_time,
|
|
|
|
|
operation_mode_id="mode",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
instr = PEBCInstruction(
|
|
|
|
|
id=resource_id,
|
|
|
|
|
execution_time=execution_time,
|
|
|
|
|
power_constraints_id="pc-123",
|
|
|
|
|
power_envelopes=[
|
|
|
|
|
PEBCPowerEnvelope(
|
|
|
|
|
id="pebcpe@1234",
|
|
|
|
|
commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
|
|
|
|
|
power_envelope_elements=[
|
|
|
|
|
PEBCPowerEnvelopeElement(
|
|
|
|
|
duration=to_duration(duration),
|
|
|
|
|
upper_limit=1010.0,
|
|
|
|
|
lower_limit=990.0,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return instr
|
|
|
|
|
|
|
|
|
|
def _build_plan(self, instructions, now):
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-test",
|
|
|
|
|
generated_at=now,
|
|
|
|
|
instructions=instructions,
|
|
|
|
|
)
|
|
|
|
|
plan._update_time_range()
|
|
|
|
|
return plan
|
|
|
|
|
|
2025-10-28 02:50:31 +01:00
|
|
|
def test_add_instruction_and_time_range(self, fixed_now):
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[]
|
|
|
|
|
)
|
|
|
|
|
instr1 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-1",
|
|
|
|
|
execution_time=fixed_now,
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr2 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-2",
|
|
|
|
|
execution_time=fixed_now.add(minutes=5),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
plan.add_instruction(instr1)
|
|
|
|
|
plan.add_instruction(instr2)
|
|
|
|
|
|
|
|
|
|
assert plan.valid_from == fixed_now
|
|
|
|
|
assert plan.valid_until is None
|
|
|
|
|
assert plan.instructions == [instr1, instr2]
|
|
|
|
|
|
|
|
|
|
def test_clear(self, fixed_now):
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[
|
|
|
|
|
OMBCInstruction(
|
|
|
|
|
resource_id="dev-1",
|
|
|
|
|
execution_time=fixed_now,
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
plan.clear()
|
|
|
|
|
assert plan.instructions == []
|
|
|
|
|
assert plan.valid_until is None
|
|
|
|
|
assert plan.valid_from is not None
|
|
|
|
|
|
|
|
|
|
def test_get_next_instruction(self, fixed_now):
|
|
|
|
|
instr1 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-1",
|
|
|
|
|
execution_time=fixed_now.subtract(minutes=1),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr2 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-2",
|
|
|
|
|
execution_time=fixed_now.add(minutes=10),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr3 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-3",
|
|
|
|
|
execution_time=fixed_now.add(minutes=5),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[instr1, instr2, instr3],
|
|
|
|
|
)
|
|
|
|
|
plan._update_time_range()
|
|
|
|
|
|
|
|
|
|
next_instr = plan.get_next_instruction(now=fixed_now)
|
|
|
|
|
assert next_instr is not None
|
|
|
|
|
assert next_instr.resource_id == "dev-3"
|
|
|
|
|
|
|
|
|
|
def test_get_instructions_for_resource(self, fixed_now):
|
|
|
|
|
instr1 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-1",
|
|
|
|
|
execution_time=fixed_now,
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr2 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-2",
|
|
|
|
|
execution_time=fixed_now,
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[instr1, instr2],
|
|
|
|
|
)
|
|
|
|
|
dev1_instructions = plan.get_instructions_for_resource("dev-1")
|
|
|
|
|
assert len(dev1_instructions) == 1
|
|
|
|
|
assert dev1_instructions[0].resource_id == "dev-1"
|
|
|
|
|
|
|
|
|
|
def test_add_various_instructions(self, fixed_now):
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[]
|
|
|
|
|
)
|
|
|
|
|
instrs = [
|
|
|
|
|
DDBCInstruction(
|
|
|
|
|
id="actuatorA@123",
|
|
|
|
|
execution_time=fixed_now,
|
|
|
|
|
actuator_id="actuatorA",
|
|
|
|
|
operation_mode_id="mode123",
|
|
|
|
|
operation_mode_factor=0.5,
|
|
|
|
|
),
|
|
|
|
|
FRBCInstruction(
|
|
|
|
|
id="actuatorB@456",
|
|
|
|
|
execution_time=fixed_now.add(minutes=1),
|
|
|
|
|
actuator_id="actuatorB",
|
|
|
|
|
operation_mode_id="FRBC_Mode_1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
),
|
|
|
|
|
OMBCInstruction(
|
|
|
|
|
id="controller@789",
|
|
|
|
|
execution_time=fixed_now.add(minutes=2),
|
|
|
|
|
operation_mode_id="OMBC_Mode_42",
|
|
|
|
|
operation_mode_factor=0.8,
|
|
|
|
|
),
|
|
|
|
|
PPBCEndInterruptionInstruction(
|
|
|
|
|
id="end_int@001",
|
|
|
|
|
execution_time=fixed_now.add(minutes=3),
|
|
|
|
|
power_profile_id="profile-123",
|
|
|
|
|
sequence_container_id="container-456",
|
|
|
|
|
power_sequence_id="seq-789",
|
|
|
|
|
),
|
|
|
|
|
PPBCStartInterruptionInstruction(
|
|
|
|
|
id="start_int@002",
|
|
|
|
|
execution_time=fixed_now.add(minutes=4),
|
|
|
|
|
power_profile_id="profile-321",
|
|
|
|
|
sequence_container_id="container-654",
|
|
|
|
|
power_sequence_id="seq-987",
|
|
|
|
|
),
|
|
|
|
|
PPBCScheduleInstruction(
|
|
|
|
|
id="schedule@003",
|
|
|
|
|
execution_time=fixed_now.add(minutes=5),
|
|
|
|
|
power_profile_id="profile-999",
|
|
|
|
|
sequence_container_id="container-888",
|
|
|
|
|
power_sequence_id="seq-777",
|
|
|
|
|
),
|
|
|
|
|
PEBCInstruction(
|
|
|
|
|
id="pebc@004",
|
|
|
|
|
execution_time=fixed_now.add(minutes=6),
|
|
|
|
|
power_constraints_id="pc-123",
|
|
|
|
|
power_envelopes=[
|
|
|
|
|
PEBCPowerEnvelope(
|
|
|
|
|
id="pebcpe@1234",
|
|
|
|
|
commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1,
|
2025-12-30 22:08:21 +01:00
|
|
|
power_envelope_elements=[
|
2025-10-28 02:50:31 +01:00
|
|
|
PEBCPowerEnvelopeElement(
|
2025-12-30 22:08:21 +01:00
|
|
|
duration=to_duration(10),
|
|
|
|
|
upper_limit=1010.0,
|
|
|
|
|
lower_limit=990.0,
|
2025-10-28 02:50:31 +01:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for instr in instrs:
|
|
|
|
|
plan.add_instruction(instr)
|
|
|
|
|
|
|
|
|
|
assert len(plan.instructions) == len(instrs)
|
|
|
|
|
assert any(
|
|
|
|
|
instr for instr in plan.get_instructions_for_resource("actuatorA")
|
|
|
|
|
if isinstance(instr, DDBCInstruction)
|
|
|
|
|
)
|
2025-12-30 22:08:21 +01:00
|
|
|
|
|
|
|
|
# -------------------------------------------
|
|
|
|
|
# Special testing for get_active_instructions
|
|
|
|
|
# -------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions(self, fixed_now):
|
|
|
|
|
instr1 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-1",
|
|
|
|
|
execution_time=fixed_now.subtract(minutes=1),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr2 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-2",
|
|
|
|
|
execution_time=fixed_now.add(minutes=1),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
instr3 = OMBCInstruction(
|
|
|
|
|
resource_id="dev-3",
|
|
|
|
|
execution_time=fixed_now.subtract(minutes=10),
|
|
|
|
|
operation_mode_id="mymode1",
|
|
|
|
|
operation_mode_factor=1.0,
|
|
|
|
|
)
|
|
|
|
|
plan = EnergyManagementPlan(
|
|
|
|
|
id="plan-123",
|
|
|
|
|
generated_at=fixed_now,
|
|
|
|
|
instructions=[instr1, instr2, instr3],
|
|
|
|
|
)
|
|
|
|
|
plan._update_time_range()
|
|
|
|
|
|
|
|
|
|
resource_ids = plan.get_resources()
|
|
|
|
|
assert resource_ids == ["dev-1", "dev-2", "dev-3"]
|
|
|
|
|
|
|
|
|
|
active = plan.get_active_instructions(now=fixed_now)
|
|
|
|
|
ids = {i.resource_id for i in active}
|
|
|
|
|
assert ids == {"dev-1", "dev-3"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_with_duration(self, fixed_now):
|
|
|
|
|
instr = self._make_instr(
|
|
|
|
|
"dev-1",
|
|
|
|
|
fixed_now.subtract(minutes=5),
|
|
|
|
|
duration=Duration(minutes=10),
|
|
|
|
|
)
|
|
|
|
|
plan = self._build_plan([instr], fixed_now)
|
|
|
|
|
active = plan.get_active_instructions(fixed_now)
|
|
|
|
|
assert {i.resource_id for i in active} == {"dev-1"}
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_expired_duration(self, fixed_now):
|
|
|
|
|
instr = self._make_instr(
|
|
|
|
|
"dev-1",
|
|
|
|
|
fixed_now.subtract(minutes=20),
|
|
|
|
|
duration=Duration(minutes=10),
|
|
|
|
|
)
|
|
|
|
|
plan = self._build_plan([instr], fixed_now)
|
|
|
|
|
assert plan.get_active_instructions(fixed_now) == []
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_end_exactly_now_not_active(self, fixed_now):
|
|
|
|
|
instr = self._make_instr(
|
|
|
|
|
"dev-1",
|
|
|
|
|
fixed_now.subtract(minutes=10),
|
|
|
|
|
duration=Duration(minutes=10),
|
|
|
|
|
)
|
|
|
|
|
plan = self._build_plan([instr], fixed_now)
|
|
|
|
|
assert plan.get_active_instructions(fixed_now) == []
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_latest_supersedes(self, fixed_now):
|
|
|
|
|
instr1 = self._make_instr(
|
|
|
|
|
"dev-1",
|
|
|
|
|
fixed_now.subtract(minutes=10),
|
|
|
|
|
duration=Duration(minutes=30),
|
|
|
|
|
)
|
|
|
|
|
instr2 = self._make_instr("dev-1", fixed_now.subtract(minutes=1))
|
|
|
|
|
plan = self._build_plan([instr1, instr2], fixed_now)
|
|
|
|
|
|
|
|
|
|
active = plan.get_active_instructions(fixed_now)
|
|
|
|
|
assert len(active) == 1
|
|
|
|
|
assert active[0] is instr2
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_mixed_resources(self, fixed_now):
|
|
|
|
|
instr1 = self._make_instr(
|
|
|
|
|
"r1",
|
|
|
|
|
fixed_now.subtract(minutes=5),
|
|
|
|
|
duration=Duration(minutes=10),
|
|
|
|
|
)
|
|
|
|
|
instr2 = self._make_instr("r2", fixed_now.subtract(minutes=1))
|
|
|
|
|
instr3 = self._make_instr("r3", fixed_now.add(minutes=10))
|
|
|
|
|
plan = self._build_plan([instr1, instr2, instr3], fixed_now)
|
|
|
|
|
|
|
|
|
|
ids = {i.resource_id for i in plan.get_active_instructions(fixed_now)}
|
|
|
|
|
assert ids == {"r1", "r2"}
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_start_exactly_now(self, fixed_now):
|
|
|
|
|
instr = self._make_instr("dev-1", fixed_now)
|
|
|
|
|
plan = self._build_plan([instr], fixed_now)
|
|
|
|
|
assert {i.resource_id for i in plan.get_active_instructions(fixed_now)} == {"dev-1"}
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_no_active(self, fixed_now):
|
|
|
|
|
instr = self._make_instr("dev-1", fixed_now.add(minutes=1))
|
|
|
|
|
plan = self._build_plan([instr], fixed_now)
|
|
|
|
|
assert plan.get_active_instructions(fixed_now) == []
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_future_does_not_override_until_reached(self, fixed_now):
|
|
|
|
|
instr1 = self._make_instr("dev-1", fixed_now.subtract(minutes=5))
|
|
|
|
|
instr2 = self._make_instr("dev-1", fixed_now.add(minutes=5))
|
|
|
|
|
plan = self._build_plan([instr1, instr2], fixed_now)
|
|
|
|
|
|
|
|
|
|
active_before = plan.get_active_instructions(fixed_now)
|
|
|
|
|
assert {i.resource_id for i in active_before} == {"dev-1"}
|
|
|
|
|
|
|
|
|
|
def test_get_active_instructions_future_overrides_once_time_reached(self, fixed_now):
|
|
|
|
|
exec_future = fixed_now.add(minutes=5)
|
|
|
|
|
instr1 = self._make_instr("dev-1", fixed_now.subtract(minutes=5))
|
|
|
|
|
instr2 = self._make_instr("dev-1", exec_future)
|
|
|
|
|
|
|
|
|
|
plan = self._build_plan([instr1, instr2], fixed_now)
|
|
|
|
|
|
|
|
|
|
active_before = plan.get_active_instructions(fixed_now)
|
|
|
|
|
assert {i.resource_id for i in active_before} == {"dev-1"}
|
|
|
|
|
|
|
|
|
|
active_after = plan.get_active_instructions(exec_future)
|
|
|
|
|
assert {i.resource_id for i in active_after} == {"dev-1"}
|