Skip to content

Commit 9dd9e9d

Browse files
tests: add logic scenario helpers
1 parent ffe905e commit 9dd9e9d

3 files changed

Lines changed: 101 additions & 64 deletions

File tree

tests/batcontrol/logic/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Shared fixtures for logic tests."""
2+
import pytest
3+
4+
from batcontrol.logic.common import CommonLogic
5+
6+
7+
@pytest.fixture(autouse=True)
8+
def reset_common_logic():
9+
"""Keep the CommonLogic singleton from leaking settings between tests."""
10+
CommonLogic._instance = None
11+
yield
12+
CommonLogic._instance = None

tests/batcontrol/logic/helpers.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Helpers for logic scenario tests."""
2+
import datetime
3+
4+
import numpy as np
5+
6+
from batcontrol.logic.common import CommonLogic
7+
from batcontrol.logic.logic_interface import CalculationInput, CalculationParameters
8+
9+
10+
CAPACITY_WH = 10240
11+
MIN_SOC = 0.10
12+
MAX_CHARGING_FROM_GRID_LIMIT = 0.89
13+
MIN_GRID_CHARGE_SOC = 0.55
14+
CHEAP_PRICE = 0.4635
15+
EXPENSIVE_PRICE = 0.7018
16+
17+
18+
def make_logic(logic_cls, *,
19+
timezone=datetime.timezone.utc,
20+
capacity_wh=CAPACITY_WH,
21+
max_charging_from_grid_limit=MAX_CHARGING_FROM_GRID_LIMIT,
22+
min_grid_charge_soc=MIN_GRID_CHARGE_SOC,
23+
preserve_min_grid_charge_soc=True,
24+
min_price_difference=0.05,
25+
min_price_difference_rel=0.10,
26+
charge_rate_multiplier=1.1,
27+
always_allow_discharge_limit=0.90,
28+
min_charge_energy=100,
29+
peak_shaving_enabled=False):
30+
"""Create a logic instance with common scenario defaults."""
31+
CommonLogic.get_instance(
32+
charge_rate_multiplier=charge_rate_multiplier,
33+
always_allow_discharge_limit=always_allow_discharge_limit,
34+
max_capacity=capacity_wh,
35+
min_charge_energy=min_charge_energy,
36+
)
37+
logic = logic_cls(timezone=timezone, interval_minutes=60)
38+
logic.set_calculation_parameters(CalculationParameters(
39+
max_charging_from_grid_limit=max_charging_from_grid_limit,
40+
min_price_difference=min_price_difference,
41+
min_price_difference_rel=min_price_difference_rel,
42+
max_capacity=capacity_wh,
43+
min_grid_charge_soc=min_grid_charge_soc,
44+
preserve_min_grid_charge_soc=preserve_min_grid_charge_soc,
45+
peak_shaving_enabled=peak_shaving_enabled,
46+
))
47+
return logic
48+
49+
50+
def make_calc_input(production, consumption, prices, soc, *,
51+
capacity_wh=CAPACITY_WH,
52+
min_soc=MIN_SOC):
53+
"""Build CalculationInput from human-readable SoC and forecast arrays."""
54+
stored_energy = capacity_wh * soc / 100
55+
min_soc_energy = capacity_wh * min_soc
56+
return CalculationInput(
57+
production=np.array(production, dtype=float),
58+
consumption=np.array(consumption, dtype=float),
59+
prices={slot: price for slot, price in enumerate(prices)},
60+
stored_energy=stored_energy,
61+
stored_usable_energy=max(0.0, stored_energy - min_soc_energy),
62+
free_capacity=capacity_wh - stored_energy,
63+
)
64+
65+
66+
def target_usable_energy(*,
67+
capacity_wh=CAPACITY_WH,
68+
min_soc=MIN_SOC,
69+
min_grid_charge_soc=MIN_GRID_CHARGE_SOC):
70+
"""Return usable energy reserved by the minimum grid-charge target."""
71+
return capacity_wh * (min_grid_charge_soc - min_soc)
Lines changed: 18 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,28 @@
11
"""Regression-style tests based on a real overnight grid-charge scenario."""
22
import datetime
33

4-
import numpy as np
54
import pytest
65
from pytest import approx
76

8-
from batcontrol.logic.common import CommonLogic
97
from batcontrol.logic.default import DefaultLogic
10-
from batcontrol.logic.logic_interface import CalculationInput, CalculationParameters
118
from batcontrol.logic.next import NextLogic
129

13-
14-
CAPACITY_WH = 10240
15-
MIN_SOC = 0.10
16-
MAX_CHARGING_FROM_GRID_LIMIT = 0.89
17-
MIN_GRID_CHARGE_SOC = 0.55
18-
CHEAP_PRICE = 0.4635
19-
EXPENSIVE_PRICE = 0.7018
20-
21-
22-
@pytest.fixture(autouse=True)
23-
def reset_common_logic():
24-
"""Keep the CommonLogic singleton from leaking settings between cases."""
25-
CommonLogic._instance = None
26-
yield
27-
CommonLogic._instance = None
10+
from .helpers import (
11+
CAPACITY_WH,
12+
CHEAP_PRICE,
13+
EXPENSIVE_PRICE,
14+
MIN_GRID_CHARGE_SOC,
15+
make_calc_input,
16+
make_logic,
17+
target_usable_energy,
18+
)
2819

2920

3021
@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic])
3122
def test_min_grid_charge_soc_preserves_battery_at_start_of_cheap_window(logic_cls):
3223
"""A fixed target preserves battery instead of discharging through a cheap plateau."""
33-
logic = _make_logic(logic_cls)
34-
calc_input = _make_input(
24+
logic = make_logic(logic_cls)
25+
calc_input = make_calc_input(
3526
# Forecast snapshot around 2026-04-28 22:00.
3627
production=[0, 0, 0, 0, 0, 0, 0, 129, 570, 1361, 2281, 2579, 2406],
3728
consumption=[854, 765, 635, 691, 571, 708, 912, 1208, 1221, 1469, 1237, 1106, 983],
@@ -45,14 +36,14 @@ def test_min_grid_charge_soc_preserves_battery_at_start_of_cheap_window(logic_cl
4536
calc_output = logic.get_calculation_output()
4637

4738
assert result.allow_discharge is False
48-
assert calc_output.reserved_energy == approx(_target_usable_energy())
39+
assert calc_output.reserved_energy == approx(target_usable_energy())
4940

5041

5142
@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic])
5243
def test_min_grid_charge_soc_charges_at_last_cheap_hour_before_expensive_window(logic_cls):
5344
"""At the last cheap hour, a fixed target causes grid charging before high prices."""
54-
logic = _make_logic(logic_cls)
55-
calc_input = _make_input(
45+
logic = make_logic(logic_cls)
46+
calc_input = make_calc_input(
5647
# Forecast snapshot around 2026-04-29 05:00.
5748
production=[128, 541, 1196, 2022, 2615, 2728],
5849
consumption=[1208, 1221, 1469, 1237, 1106, 983],
@@ -75,8 +66,8 @@ def test_min_grid_charge_soc_charges_at_last_cheap_hour_before_expensive_window(
7566
@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic])
7667
def test_min_grid_charge_soc_stops_grid_charging_when_gap_is_below_threshold(logic_cls):
7768
"""When the fixed target is nearly reached, avoid a tiny final grid-charge burst."""
78-
logic = _make_logic(logic_cls)
79-
calc_input = _make_input(
69+
logic = make_logic(logic_cls)
70+
calc_input = make_calc_input(
8071
# Similar to 2026-04-29 14:54: target gap was below min_recharge_amount.
8172
production=[0, 0],
8273
consumption=[1000, 1000],
@@ -97,8 +88,8 @@ def test_min_grid_charge_soc_stops_grid_charging_when_gap_is_below_threshold(log
9788
@pytest.mark.parametrize("logic_cls", [DefaultLogic, NextLogic])
9889
def test_min_grid_charge_soc_allows_discharge_after_target_when_price_is_high(logic_cls):
9990
"""After reaching the target, high-price periods can use the preserved battery."""
100-
logic = _make_logic(logic_cls)
101-
calc_input = _make_input(
91+
logic = make_logic(logic_cls)
92+
calc_input = make_calc_input(
10293
# Similar to 2026-04-29 15:00 after the battery reached the 55% target.
10394
production=[2491, 2118, 1946, 1687, 1234, 664, 179, 0, 0, 0],
10495
consumption=[746, 986, 989, 1026, 1361, 1316, 1047, 790, 564, 665],
@@ -114,40 +105,3 @@ def test_min_grid_charge_soc_allows_discharge_after_target_when_price_is_high(lo
114105
assert result.allow_discharge is True
115106
assert result.charge_from_grid is False
116107
assert calc_output.reserved_energy == 0.0
117-
118-
119-
def _make_logic(logic_cls):
120-
CommonLogic.get_instance(
121-
charge_rate_multiplier=1.1,
122-
always_allow_discharge_limit=0.90,
123-
max_capacity=CAPACITY_WH,
124-
min_charge_energy=100,
125-
)
126-
logic = logic_cls(timezone=datetime.timezone.utc, interval_minutes=60)
127-
logic.set_calculation_parameters(CalculationParameters(
128-
max_charging_from_grid_limit=MAX_CHARGING_FROM_GRID_LIMIT,
129-
min_price_difference=0.05,
130-
min_price_difference_rel=0.10,
131-
max_capacity=CAPACITY_WH,
132-
min_grid_charge_soc=MIN_GRID_CHARGE_SOC,
133-
preserve_min_grid_charge_soc=True,
134-
peak_shaving_enabled=False,
135-
))
136-
return logic
137-
138-
139-
def _make_input(production, consumption, prices, soc):
140-
stored_energy = CAPACITY_WH * soc / 100
141-
min_soc_energy = CAPACITY_WH * MIN_SOC
142-
return CalculationInput(
143-
production=np.array(production, dtype=float),
144-
consumption=np.array(consumption, dtype=float),
145-
prices={slot: price for slot, price in enumerate(prices)},
146-
stored_energy=stored_energy,
147-
stored_usable_energy=max(0.0, stored_energy - min_soc_energy),
148-
free_capacity=CAPACITY_WH - stored_energy,
149-
)
150-
151-
152-
def _target_usable_energy():
153-
return CAPACITY_WH * (MIN_GRID_CHARGE_SOC - MIN_SOC)

0 commit comments

Comments
 (0)