Files
EOS/tests/test_devicesprofilebattery.py
T

1468 lines
59 KiB
Python
Raw Normal View History

2026-11-09 19:11:15 +00:00
"""Tests for the batteryprofile module.
The module exercises the Pydantic validation rules, the interpolation
strategies, the out-of-range behaviors, the hard-cutoff handling, the
precomputed lookup path and the JSON file entry point of the
BatteryProfileSettings configuration model.
"""
import json
from pathlib import Path
from typing import Any
import numpy as np
import pytest
from pydantic import ValidationError
from akkudoktoreos.devices.profile.batteryprofile import (
Axes,
BatteryProfile,
BatteryProfileSettings,
ChargeDischargeLimitMap,
EfficiencyCurve,
EfficiencyInterpolation,
HardCutoffs,
Interpolation,
OutOfRangeBehavior,
PrecomputedChargeDischargeLimitMap,
PrecomputedEfficiencyCurve,
Units,
)
from akkudoktoreos.devices.profile.profilestore import get_profile_store
# --- helpers -------------------------------------------------------------
# Values of the required fields that the tests do not vary.
TEST_AXES = {
"temperature_degC": [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50],
"soc_percent": [0, 5, 10, 20, 40, 60, 80, 90, 95, 100],
}
TEST_CUTOFFS = {
"charge_allowed_below_degC": -10.0,
"charge_allowed_above_degC": 55.0,
"discharge_allowed_below_degC": -15.0,
"discharge_allowed_above_degC": 60.0,
}
TEST_UNITS = {"temperature": "degC", "soc": "percent", "limit": "C-rate"}
TEST_CURVE = {"c_rate": [0.1, 0.5, 1.0], "efficiency": [0.90, 0.96, 0.92]}
def make_grid(rows: int = 10, cols: int = 10, value: float = 0.5) -> list[list[float]]:
"""Return a constant rows x cols grid filled with value."""
return [[value for _ in range(cols)] for _ in range(rows)]
def make_limits(
charge_values: list[list[float]] | None = None,
discharge_values: list[list[float]] | None = None,
**overrides,
) -> BatteryProfileSettings:
"""Build a BatteryProfileSettings with sensible defaults.
Args:
charge_values: Optional charge grid; defaults to a constant 0.5 grid.
discharge_values: Optional discharge grid; defaults to a constant 0.5
grid.
**overrides: Further keyword arguments forwarded to the model. The
required fields get test values unless the caller provides them.
Returns:
A validated BatteryProfileSettings instance.
"""
payload: dict[str, Any] = dict(
battery_model="Test Battery",
technical_min_soc_percent=0.0,
technical_max_soc_percent=100.0,
charge_efficiency=EfficiencyCurve.model_validate(TEST_CURVE),
discharge_efficiency=EfficiencyCurve.model_validate(TEST_CURVE),
units=Units.model_validate(TEST_UNITS),
axes=Axes.model_validate(TEST_AXES),
hard_cutoffs=HardCutoffs.model_validate(TEST_CUTOFFS),
charge_limit_crate=ChargeDischargeLimitMap(values=charge_values or make_grid()),
discharge_limit_crate=ChargeDischargeLimitMap(values=discharge_values or make_grid()),
)
payload.update(overrides)
return BatteryProfileSettings(**payload)
def make_efficiency_curve(
c_rate: list[float] | None = None,
efficiency: list[float] | None = None,
**overrides,
) -> EfficiencyCurve:
"""Build a validated efficiency curve with useful defaults."""
payload: dict[str, Any] = {
"c_rate": c_rate or [0.1, 0.5, 1.0],
"efficiency": efficiency or [0.90, 0.96, 0.92],
}
payload.update(overrides)
return EfficiencyCurve(**payload)
def make_document(charge_values=None, discharge_values=None, **overrides) -> dict:
"""Return a JSON-serializable document for a full limits configuration.
Args:
charge_values: Optional charge grid; defaults to a constant 0.5 grid.
discharge_values: Optional discharge grid; defaults to a constant 0.5
grid.
**overrides: Further top-level keys forwarded to the document.
Returns:
A dict that can be written with ``json.dump`` and loaded back.
"""
doc = {
"schema_version": "1.1",
"battery_model": "Test Battery",
"chemistry": "LFP",
"nominal_voltage_v": 102.4,
"technical_min_soc_percent": 0.0,
"technical_max_soc_percent": 100.0,
"charge_efficiency": dict(TEST_CURVE),
"discharge_efficiency": dict(TEST_CURVE),
"units": dict(TEST_UNITS),
"interpolation": "bilinear",
"out_of_range_behavior": "clamp_to_nearest",
"axes": {
"temperature_degC": [-10, -5, 0, 5, 10, 15, 25, 35, 45, 50],
"soc_percent": [0, 5, 10, 20, 40, 60, 80, 90, 95, 100],
},
"charge_limit_crate": {
"description": "Max charge C-rate; rows = temperature, cols = SOC",
"values": charge_values or make_grid(),
},
"discharge_limit_crate": {
"description": "Max discharge C-rate; rows = temperature, cols = SOC",
"values": discharge_values or make_grid(),
},
"hard_cutoffs": {
"charge_allowed_below_degC": -10.0,
"charge_allowed_above_degC": 55.0,
"discharge_allowed_below_degC": -15.0,
"discharge_allowed_above_degC": 60.0,
},
}
doc.update(overrides)
return doc
# --- Units ---------------------------------------------------------------
class TestUnits:
"""Tests for the Units defaults and schema metadata."""
def test_axis_units_default_but_limit_unit_is_required(self):
u = Units(limit="C-rate")
assert u.temperature == "degC"
assert u.soc == "percent"
with pytest.raises(ValidationError, match="limit"):
Units.model_validate({})
def test_json_schema_contains_descriptions(self):
schema = Units.model_json_schema()
assert schema["properties"]["temperature"]["description"]
assert schema["properties"]["temperature"]["examples"] == ["degC"]
# --- EfficiencyCurve: configuration validation and lookup ------------------
class TestEfficiencyCurveValidation:
"""Tests for C-rate efficiency curve validation."""
def test_accepts_well_formed_curve(self):
curve = make_efficiency_curve()
assert curve.c_rate == [0.1, 0.5, 1.0]
assert curve.efficiency == [0.90, 0.96, 0.92]
assert curve.interpolation is EfficiencyInterpolation.LINEAR
assert curve.out_of_range_behavior is OutOfRangeBehavior.CLAMP_TO_NEAREST
def test_rejects_negative_c_rate(self):
with pytest.raises(ValidationError, match="non-negative"):
make_efficiency_curve(c_rate=[-0.1, 0.5], efficiency=[0.9, 0.95])
def test_rejects_non_increasing_c_rate(self):
with pytest.raises(ValidationError, match="strictly increasing"):
make_efficiency_curve(c_rate=[0.1, 0.1], efficiency=[0.9, 0.95])
def test_rejects_efficiency_outside_valid_range(self):
for values in ([0.0, 0.9], [-0.1, 0.9], [0.9, 1.01]):
with pytest.raises(ValidationError, match=r"\(0, 1\]"):
make_efficiency_curve(c_rate=[0.1, 0.5], efficiency=list(values))
def test_rejects_mismatched_lengths(self):
with pytest.raises(ValidationError, match="same number"):
make_efficiency_curve(c_rate=[0.1, 0.5, 1.0], efficiency=[0.9, 0.95])
def test_json_schema_contains_curve_descriptions(self):
schema = EfficiencyCurve.model_json_schema()
assert schema["properties"]["c_rate"]["description"]
assert schema["properties"]["efficiency"]["description"]
class TestEfficiencyCurveLookup:
"""Tests for the configuration model's reference lookup implementation."""
def test_linear_interpolation(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 1.0]
)
assert curve.lookup(0.1) == pytest.approx(0.8)
assert curve.lookup(0.3) == pytest.approx(0.9)
assert curve.lookup(0.5) == pytest.approx(1.0)
def test_nearest_interpolation(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5, 1.0],
efficiency=[0.8, 0.9, 0.95],
interpolation=EfficiencyInterpolation.NEAREST,
)
assert curve.lookup(0.2) == pytest.approx(0.8)
assert curve.lookup(0.4) == pytest.approx(0.9)
def test_nearest_tie_selects_lower_breakpoint(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9],
interpolation=EfficiencyInterpolation.NEAREST,
)
assert curve.lookup(0.3) == pytest.approx(0.8)
def test_none_interpolation_requires_exact_breakpoint(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9],
interpolation=EfficiencyInterpolation.NONE,
)
assert curve.lookup(0.5) == pytest.approx(0.9)
assert curve.lookup(0.3) is None
def test_out_of_range_clamps_to_endpoints(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9]
)
assert curve.lookup(0.0) == pytest.approx(0.8)
assert curve.lookup(1.0) == pytest.approx(0.9)
def test_out_of_range_rejects_query(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9],
out_of_range_behavior=OutOfRangeBehavior.REJECT,
)
assert curve.lookup(0.0) is None
assert curve.lookup(1.0) is None
assert curve.lookup(0.3) == pytest.approx(0.85)
# --- PrecomputedEfficiencyCurve: standalone runtime lookup ------------------
class TestPrecomputedEfficiencyCurve:
"""Tests for the immutable precomputed efficiency-curve representation."""
@pytest.mark.parametrize(
"interpolation",
[
EfficiencyInterpolation.LINEAR,
EfficiencyInterpolation.NEAREST,
EfficiencyInterpolation.NONE,
],
)
@pytest.mark.parametrize(
"out_of_range_behavior",
[
OutOfRangeBehavior.CLAMP_TO_NEAREST,
OutOfRangeBehavior.REJECT,
],
)
def test_precomputed_lookup_matches_reference(
self, interpolation, out_of_range_behavior
):
curve = make_efficiency_curve(
c_rate=[0.05, 0.2, 0.5, 1.0],
efficiency=[0.88, 0.94, 0.97, 0.91],
interpolation=interpolation,
out_of_range_behavior=out_of_range_behavior,
)
precomputed = PrecomputedEfficiencyCurve.from_curve(curve)
queries = [-0.1, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1.0, 1.2]
for c_rate in queries:
expected = curve.lookup(c_rate)
actual = precomputed.lookup(c_rate)
if expected is None:
assert actual is None
else:
assert actual == pytest.approx(expected, abs=1e-12)
def test_precomputes_segment_coefficients_and_midpoints(self):
curve = make_efficiency_curve(
c_rate=[0.1, 0.5, 1.0],
efficiency=[0.8, 1.0, 0.9],
)
precomputed = PrecomputedEfficiencyCurve.from_curve(curve)
assert len(precomputed.slopes) == 2
assert len(precomputed.intercepts) == 2
assert precomputed.midpoints == pytest.approx((0.3, 0.75))
def test_runtime_data_is_immutable(self):
precomputed = PrecomputedEfficiencyCurve.from_curve(make_efficiency_curve())
with pytest.raises((AttributeError, TypeError)):
precomputed.c_rate = (0.0, 1.0) # type: ignore[misc]
class TestBatteryProfileEfficiency:
"""Tests for efficiency-curve conversion and access on BatteryProfile."""
def test_to_limits_precomputes_both_efficiency_curves(self):
charge_curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.9, 0.96]
)
discharge_curve = make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.91, 0.95]
)
profile = make_limits(
charge_efficiency=charge_curve,
discharge_efficiency=discharge_curve,
).to_limits()
assert isinstance(profile.charge_efficiency, PrecomputedEfficiencyCurve)
assert isinstance(profile.discharge_efficiency, PrecomputedEfficiencyCurve)
def test_charge_and_discharge_efficiencies_are_independent(self):
profile = make_limits(
charge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9]
),
discharge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.7, 0.95]
),
).to_limits()
assert profile.charge_efficiency_at(0.3) == pytest.approx(0.85)
assert profile.discharge_efficiency_at(0.3) == pytest.approx(0.825)
def test_efficiency_accessors_use_absolute_c_rate(self):
profile = make_limits(
charge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.8, 0.9]
),
discharge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.7, 0.9]
),
).to_limits()
assert profile.charge_efficiency_at(-0.3) == pytest.approx(0.85)
assert profile.discharge_efficiency_at(-0.3) == pytest.approx(0.8)
@pytest.mark.parametrize("field", ["charge_efficiency", "discharge_efficiency"])
def test_efficiency_curves_are_required(self, field):
with pytest.raises(ValidationError, match=field):
make_limits(**{field: None})
def test_profile_carries_new_model_characteristics(self):
profile = make_limits(
max_charge_power_w=5000.0,
max_discharge_power_w=4500.0,
technical_min_soc_percent=5.0,
technical_max_soc_percent=95.0,
).to_limits()
assert profile.max_charge_power_w == pytest.approx(5000.0)
assert profile.max_discharge_power_w == pytest.approx(4500.0)
assert profile.technical_min_soc_percent == pytest.approx(5.0)
assert profile.technical_max_soc_percent == pytest.approx(95.0)
def test_profile_carries_full_charge_characteristics(self):
profile = make_limits(
full_charge_interval_days=21.0, full_charge_detect_percentage=98
).to_limits()
assert profile.full_charge_interval_days == pytest.approx(21.0)
assert profile.full_charge_detect_percentage == 98
def test_full_charge_characteristics_default_to_no_periodic_full_charge(self):
profile = make_limits().to_limits()
assert profile.full_charge_interval_days is None
assert profile.full_charge_detect_percentage == 99
@pytest.mark.parametrize(
"overrides",
[
{"full_charge_interval_days": 0.0},
{"full_charge_interval_days": float("inf")},
{"full_charge_detect_percentage": 0},
{"full_charge_detect_percentage": 101},
],
)
def test_rejects_invalid_full_charge_characteristics(self, overrides):
with pytest.raises(ValidationError):
make_limits(**overrides)
# --- Axes ----------------------------------------------------------------
class TestAxes:
"""Tests for axis validation."""
def test_axes_are_required(self):
with pytest.raises(ValidationError, match="temperature_degC"):
Axes.model_validate({"soc_percent": [0, 100]})
with pytest.raises(ValidationError, match="soc_percent"):
Axes.model_validate({"temperature_degC": [0, 10]})
def test_temperature_must_be_strictly_increasing(self):
with pytest.raises(ValidationError, match="strictly increasing"):
Axes(temperature_degC=[0, 5, 5, 10], soc_percent=[0, 50, 100])
def test_soc_must_be_strictly_increasing(self):
with pytest.raises(ValidationError, match="strictly increasing"):
Axes(temperature_degC=[0, 10], soc_percent=[0, 50, 40])
def test_soc_bounds_enforced(self):
with pytest.raises(ValidationError, match=r"\[0, 100\]"):
Axes(temperature_degC=[0, 10], soc_percent=[-1, 50])
with pytest.raises(ValidationError, match=r"\[0, 100\]"):
Axes(temperature_degC=[0, 10], soc_percent=[50, 101])
def test_min_length_enforced(self):
with pytest.raises(ValidationError):
Axes(temperature_degC=[0], soc_percent=[0, 100])
# --- ChargeDischargeLimitMap ------------------------------------------------------------
class TestChargeDischargeLimitMap:
"""Tests for ChargeDischargeLimitMap value validation."""
def test_accepts_well_formed_grid(self):
m = ChargeDischargeLimitMap(values=make_grid(2, 3, 0.0))
assert len(m.values) == 2
assert len(m.values[0]) == 3
def test_rejects_negative_values(self):
with pytest.raises(ValidationError, match="non-negative"):
ChargeDischargeLimitMap(values=[[0.1, -0.1], [0.2, 0.3]])
def test_rejects_single_row(self):
with pytest.raises(ValidationError):
ChargeDischargeLimitMap(values=[[0.1, 0.2]])
def test_description_is_optional(self):
assert ChargeDischargeLimitMap(values=make_grid()).description is None
# --- HardCutoffs ---------------------------------------------------------
class TestHardCutoffs:
"""Tests for cutoff ordering validation."""
def test_all_cutoffs_are_required(self):
for field in TEST_CUTOFFS:
values = {k: v for k, v in TEST_CUTOFFS.items() if k != field}
with pytest.raises(ValidationError, match=field):
HardCutoffs.model_validate(values)
def test_charge_bounds_must_be_ordered(self):
with pytest.raises(ValidationError, match="charge cutoff"):
HardCutoffs.model_validate(
{
**TEST_CUTOFFS,
"charge_allowed_below_degC": 60.0,
"charge_allowed_above_degC": 50.0,
}
)
def test_discharge_bounds_must_be_ordered(self):
with pytest.raises(ValidationError, match="discharge cutoff"):
HardCutoffs.model_validate(
{
**TEST_CUTOFFS,
"discharge_allowed_below_degC": 60.0,
"discharge_allowed_above_degC": 50.0,
}
)
# --- BatteryProfileSettings: validation -----------------------------------
class TestBatteryProfileSettingsValidation:
"""Tests for cross-field validation of BatteryProfileSettings."""
def test_minimal_config_is_valid(self):
limits = make_limits()
assert limits.interpolation is Interpolation.BILINEAR
assert limits.out_of_range_behavior is OutOfRangeBehavior.CLAMP_TO_NEAREST
def test_charge_grid_must_match_axes_rows(self):
grid = make_grid(rows=3, cols=10)
with pytest.raises(ValidationError, match="charge_limit_crate.*rows"):
make_limits(charge_values=grid)
def test_charge_grid_must_match_axes_cols(self):
grid = make_grid(rows=10, cols=4)
with pytest.raises(ValidationError, match="charge_limit_crate.*columns"):
make_limits(charge_values=grid)
def test_discharge_grid_must_match_axes(self):
grid = make_grid(rows=3, cols=10)
with pytest.raises(ValidationError, match="discharge_limit_crate.*rows"):
make_limits(discharge_values=grid)
def test_custom_axes_respected(self):
axes = Axes(temperature_degC=[0, 10, 20], soc_percent=[0, 50, 100])
limits = make_limits(
charge_values=make_grid(3, 3),
discharge_values=make_grid(3, 3),
axes=axes,
)
assert len(limits.charge_limit_crate.values) == 3
def test_custom_axes_shape_mismatch_rejected(self):
axes = Axes(temperature_degC=[0, 10, 20], soc_percent=[0, 50, 100])
with pytest.raises(ValidationError, match="charge_limit_crate"):
make_limits(
charge_values=make_grid(10, 10),
discharge_values=make_grid(3, 3),
axes=axes,
)
# --- BatteryProfileSettings: interpolation --------------------------------
class TestInterpolationBilinear:
"""Tests for bilinear interpolation."""
def test_exact_grid_point_returns_stored_value(self):
values = make_grid(2, 2, 0.0)
values[0][0] = 1.0
values[0][1] = 2.0
values[1][0] = 3.0
values[1][1] = 4.0
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
)
assert limits.max_charge_crate(0.0, 0.0) == pytest.approx(1.0)
assert limits.max_charge_crate(10.0, 100.0) == pytest.approx(4.0)
def test_midpoint_is_average_of_corners(self):
values = [[1.0, 2.0], [3.0, 4.0]]
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
)
# Center of the cell: 0.25*(1+2+3+4) = 2.5
assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(2.5)
def test_one_direction_linear(self):
values = [[1.0, 2.0], [3.0, 4.0]]
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
)
# Halfway along SOC only, at the first temperature row: (1+2)/2 = 1.5
assert limits.max_charge_crate(0.0, 50.0) == pytest.approx(1.5)
# Halfway along temperature only, at the first SOC column: (1+3)/2 = 2.0
assert limits.max_charge_crate(5.0, 0.0) == pytest.approx(2.0)
class TestInterpolationNearest:
"""Tests for nearest-neighbor interpolation."""
def test_snaps_to_nearest_point(self):
values = [[1.0, 2.0], [3.0, 4.0]]
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
interpolation=Interpolation.NEAREST,
)
assert limits.max_charge_crate(1.0, 10.0) == pytest.approx(1.0)
assert limits.max_charge_crate(9.0, 90.0) == pytest.approx(4.0)
class TestInterpolationNone:
"""Tests for strict grid-point-only interpolation."""
def test_off_grid_returns_none(self):
values = [[1.0, 2.0], [3.0, 4.0]]
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
interpolation=Interpolation.NONE,
)
assert limits.max_charge_crate(5.0, 50.0) is None
def test_on_grid_returns_value(self):
values = [[1.0, 2.0], [3.0, 4.0]]
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
interpolation=Interpolation.NONE,
)
assert limits.max_charge_crate(10.0, 100.0) == pytest.approx(4.0)
# --- BatteryProfileSettings: out-of-range behavior ------------------------
class TestOutOfRange:
"""Tests for clamping versus rejection outside the axis range."""
def _limits(self, behavior: OutOfRangeBehavior) -> BatteryProfileSettings:
# Axes deliberately narrower than the hard cutoffs so that queries can
# land outside the axis but inside the cutoffs.
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
cutoffs = HardCutoffs(
charge_allowed_below_degC=-20.0,
charge_allowed_above_degC=40.0,
discharge_allowed_below_degC=-20.0,
discharge_allowed_above_degC=40.0,
)
return make_limits(
charge_values=make_grid(2, 2, 0.7),
discharge_values=make_grid(2, 2, 0.7),
axes=axes,
hard_cutoffs=cutoffs,
out_of_range_behavior=behavior,
)
def test_clamp_returns_edge_value(self):
limits = self._limits(OutOfRangeBehavior.CLAMP_TO_NEAREST)
assert limits.max_charge_crate(15.0, 50.0) == pytest.approx(0.7)
assert limits.max_charge_crate(5.0, 200.0) == pytest.approx(0.7)
def test_reject_returns_none(self):
limits = self._limits(OutOfRangeBehavior.REJECT)
assert limits.max_charge_crate(15.0, 50.0) is None
assert limits.max_charge_crate(5.0, 200.0) is None
assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.7)
# --- BatteryProfileSettings: hard cutoffs ---------------------------------
class TestHardCutoffsRuntime:
"""Tests that hard cutoffs override the maps."""
def test_charge_below_lower_cutoff_returns_none(self):
limits = make_limits()
c = limits.hard_cutoffs
assert limits.max_charge_crate(c.charge_allowed_below_degC - 0.1, 50.0) is None
def test_charge_above_upper_cutoff_returns_none(self):
limits = make_limits()
c = limits.hard_cutoffs
assert limits.max_charge_crate(c.charge_allowed_above_degC + 0.1, 50.0) is None
def test_discharge_below_lower_cutoff_returns_none(self):
limits = make_limits()
c = limits.hard_cutoffs
assert limits.max_discharge_crate(c.discharge_allowed_below_degC - 0.1, 50.0) is None
def test_charge_and_discharge_cutoffs_are_independent(self):
limits = make_limits()
assert limits.max_discharge_crate(-12.0, 50.0) is not None
assert limits.max_charge_crate(-12.0, 50.0) is None
def test_inside_cutoffs_returns_map_value(self):
limits = make_limits(charge_values=make_grid(value=0.42))
assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.42)
# --- BatteryProfileSettings: accessors ------------------------------------
class TestAccessors:
"""Tests that the two accessors read their respective maps."""
def test_charge_and_discharge_use_different_maps(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
charge = make_grid(2, 2, 0.3)
discharge = make_grid(2, 2, 0.9)
limits = make_limits(
charge_values=charge,
discharge_values=discharge,
axes=axes,
)
assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.3)
assert limits.max_discharge_crate(5.0, 50.0) == pytest.approx(0.9)
# --- PrecomputedChargeDischargeLimitMap: standalone ------------------------------------
class TestPrecomputedChargeDischargeLimitMap:
"""Tests for the PrecomputedChargeDischargeLimitMap dataclass on its own."""
def _map(self, fill=None) -> PrecomputedChargeDischargeLimitMap:
grid = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=float)
return PrecomputedChargeDischargeLimitMap(
grid=grid,
t_min=0.0,
t_step=10.0,
s_min=0.0,
s_step=100.0,
n_temp=2,
n_soc=2,
fill=fill,
)
def test_lookup_returns_stored_value_at_grid_points(self):
m = self._map()
assert m.lookup(0.0, 0.0) == pytest.approx(1.0)
assert m.lookup(10.0, 100.0) == pytest.approx(4.0)
def test_lookup_rounds_to_nearest_cell(self):
m = self._map()
assert m.lookup(1.0, 10.0) == pytest.approx(1.0)
assert m.lookup(9.0, 90.0) == pytest.approx(4.0)
def test_lookup_out_of_bounds_returns_fill(self):
m = self._map(fill=0.0)
# Below the lower bound: previously rounded to index 0 by mistake
assert m.lookup(-5.0, 0.0) == pytest.approx(0.0)
assert m.lookup(20.0, 0.0) == pytest.approx(0.0)
assert m.lookup(0.0, 200.0) == pytest.approx(0.0)
def test_lookup_out_of_bounds_returns_none_when_fill_is_none(self):
m = self._map(fill=None)
assert m.lookup(-5.0, 0.0) is None
assert m.lookup(20.0, 0.0) is None
assert m.lookup(0.0, 200.0) is None
def test_lookup_many_matches_lookup(self):
m = self._map(fill=None)
temps = np.array([0.0, 10.0, 5.0, -5.0])
socs = np.array([0.0, 100.0, 50.0, 50.0])
result = m.lookup_many(temps, socs)
expected = [m.lookup(t, s) for t, s in zip(temps, socs)]
for got, exp in zip(result, expected):
if exp is None:
assert np.isnan(got)
else:
assert got == pytest.approx(exp)
def test_lookup_many_uses_fill(self):
m = self._map(fill=0.0)
temps = np.array([-5.0, 0.0, 20.0])
socs = np.array([0.0, 0.0, 0.0])
result = m.lookup_many(temps, socs)
assert result[0] == pytest.approx(0.0)
assert result[1] == pytest.approx(1.0)
assert result[2] == pytest.approx(0.0)
def test_lookup_many_preserves_shape(self):
m = self._map()
temps = np.array([[0.0, 10.0], [10.0, 0.0]])
socs = np.array([[0.0, 100.0], [0.0, 100.0]])
result = m.lookup_many(temps, socs)
assert result.shape == temps.shape
# --- BatteryProfileSettings: precompute_charge_discharge_limit_map -----------------------------------
class TestPrecompute:
"""Tests for the precompute_charge_discharge_limit_map path on BatteryProfileSettings."""
def test_returns_two_precomputed_maps(self):
charge, discharge = make_limits().precompute_charge_discharge_limit_map()
assert isinstance(charge, PrecomputedChargeDischargeLimitMap)
assert isinstance(discharge, PrecomputedChargeDischargeLimitMap)
def test_charge_and_discharge_use_different_maps(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
limits = make_limits(
charge_values=make_grid(2, 2, 0.3),
discharge_values=make_grid(2, 2, 0.9),
axes=axes,
)
charge, discharge = limits.precompute_charge_discharge_limit_map(t_step=1.0, s_step=1.0)
assert charge.lookup(5.0, 50.0) == pytest.approx(0.3)
assert discharge.lookup(5.0, 50.0) == pytest.approx(0.9)
def test_precomputed_matches_reference_at_axis_points(self):
# Non-trivial, non-uniform grid so any interpolation bug shows up.
axes = Axes(
temperature_degC=[-10, 0, 10, 25, 40],
soc_percent=[0, 20, 50, 80, 100],
)
charge_values = [
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.1, 0.2, 0.3, 0.2, 0.05],
[0.4, 0.5, 0.6, 0.4, 0.1],
[0.5, 0.6, 0.7, 0.5, 0.15],
[0.3, 0.4, 0.5, 0.3, 0.1],
]
limits = make_limits(
charge_values=charge_values,
discharge_values=charge_values,
axes=axes,
)
charge_map, discharge_map = limits.precompute_charge_discharge_limit_map(t_step=1.0, s_step=1.0)
for t in axes.temperature_degC:
for s in axes.soc_percent:
ref_charge = limits.max_charge_crate(t, s)
ref_discharge = limits.max_discharge_crate(t, s)
if ref_charge is None:
assert charge_map.lookup(t, s) is None
else:
assert charge_map.lookup(t, s) == pytest.approx(ref_charge, abs=1e-9)
if ref_discharge is None:
assert discharge_map.lookup(t, s) is None
else:
assert discharge_map.lookup(t, s) == pytest.approx(ref_discharge, abs=1e-9)
def test_precomputed_without_cutoffs_covers_axis_only(self):
limits = make_limits()
charge_map, _ = limits.precompute_charge_discharge_limit_map(apply_cutoffs=False)
assert charge_map.t_min == pytest.approx(limits.axes.temperature_degC[0])
def test_precomputed_grid_point_matches_reference(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
values = [[1.0, 2.0], [3.0, 4.0]]
limits = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
)
charge_map, _ = limits.precompute_charge_discharge_limit_map(t_step=10.0, s_step=100.0, apply_cutoffs=False)
assert charge_map.lookup(0.0, 0.0) == pytest.approx(1.0)
assert charge_map.lookup(10.0, 100.0) == pytest.approx(4.0)
def test_cutoffs_are_baked_in_by_default(self):
# Discharge down to -15, charge only to -10.
limits = make_limits()
charge_map, discharge_map = limits.precompute_charge_discharge_limit_map()
# -12 degC: discharge still fine, charge inhibited
assert charge_map.lookup(-12.0, 50.0) is None
assert discharge_map.lookup(-12.0, 50.0) is not None
def test_cutoffs_can_be_disabled(self):
limits = make_limits()
charge_map, _ = limits.precompute_charge_discharge_limit_map(apply_cutoffs=False)
# Inside the axis and above the charge cutoff: a real value.
value = charge_map.lookup(-9.0, 50.0)
assert value is not None
def test_rejects_non_positive_step(self):
limits = make_limits()
with pytest.raises(ValueError, match="positive"):
limits.precompute_charge_discharge_limit_map(t_step=0.0)
with pytest.raises(ValueError, match="positive"):
limits.precompute_charge_discharge_limit_map(s_step=-1.0)
def test_out_of_range_semantics_preserved_reject(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
cutoffs = HardCutoffs(
charge_allowed_below_degC=-20.0,
charge_allowed_above_degC=40.0,
discharge_allowed_below_degC=-20.0,
discharge_allowed_above_degC=40.0,
)
limits = make_limits(
charge_values=make_grid(2, 2, 0.7),
discharge_values=make_grid(2, 2, 0.7),
axes=axes,
hard_cutoffs=cutoffs,
out_of_range_behavior=OutOfRangeBehavior.REJECT,
)
charge_map, _ = limits.precompute_charge_discharge_limit_map()
# Outside the axis, inside the cutoff -> None (REJECT)
assert charge_map.lookup(15.0, 50.0) is None
def test_out_of_range_semantics_preserved_clamp(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
cutoffs = HardCutoffs(
charge_allowed_below_degC=-20.0,
charge_allowed_above_degC=40.0,
discharge_allowed_below_degC=-20.0,
discharge_allowed_above_degC=40.0,
)
limits = make_limits(
charge_values=make_grid(2, 2, 0.7),
discharge_values=make_grid(2, 2, 0.7),
axes=axes,
hard_cutoffs=cutoffs,
out_of_range_behavior=OutOfRangeBehavior.CLAMP_TO_NEAREST,
)
charge_map, _ = limits.precompute_charge_discharge_limit_map()
# Outside the axis, inside the cutoff -> clamped edge value 0.7
assert charge_map.lookup(15.0, 50.0) == pytest.approx(0.7)
@staticmethod
def cutoff_limits(charge_lo: float, charge_hi: float) -> BatteryProfileSettings:
"""Constant 0.5 C limits on a 0..50 degC axis with the given charge cutoffs."""
return make_limits(
charge_values=make_grid(2, 2, 0.5),
discharge_values=make_grid(2, 2, 0.5),
axes=Axes(temperature_degC=[0, 50], soc_percent=[0, 100]),
hard_cutoffs=HardCutoffs(
charge_allowed_below_degC=charge_lo,
charge_allowed_above_degC=charge_hi,
discharge_allowed_below_degC=-20.0,
discharge_allowed_above_degC=60.0,
),
)
@pytest.mark.parametrize(
("temperature", "allowed"),
[(45.0, True), (45.4, False), (45.6, False), (44.6, True)],
)
def test_upper_cutoff_is_exact(self, temperature, allowed):
"""Charging is inhibited strictly above the cutoff, not at the rounded grid cell."""
charge_map, _ = self.cutoff_limits(-10.0, 45.0).precompute_charge_discharge_limit_map()
assert (charge_map.lookup(temperature, 50.0) is not None) is allowed
many = charge_map.lookup_many(np.array([temperature]), np.array([50.0]))
assert (not np.isnan(many[0])) is allowed
@pytest.mark.parametrize(
("temperature", "allowed"),
[(0.3, True), (0.4, True), (0.2, False), (-0.4, False)],
)
def test_lower_cutoff_between_grid_points_is_exact(self, temperature, allowed):
"""A cutoff that is no grid point neither allows below nor inhibits above it."""
limits = self.cutoff_limits(0.3, 45.0)
# The axis starts at -5 degC, so the 1 degC grid points are integers.
limits = limits.model_copy(
update={"axes": Axes(temperature_degC=[-5, 50], soc_percent=[0, 100])}
)
charge_map, _ = limits.precompute_charge_discharge_limit_map()
assert (charge_map.lookup(temperature, 50.0) is not None) is allowed
def test_lookup_many_matches_single_lookup(self):
limits = make_limits()
charge_map, _ = limits.precompute_charge_discharge_limit_map()
temps = np.array([25.0, 30.0, -12.0, 45.0])
socs = np.array([50.0, 60.0, 50.0, 90.0])
many = charge_map.lookup_many(temps, socs)
for t, s, got in zip(temps, socs, many):
single = charge_map.lookup(float(t), float(s))
if single is None:
assert np.isnan(got)
else:
assert got == pytest.approx(single)
# --- BatteryProfile: the user-facing object ------------------------------
class TestBatteryProfile:
"""Tests for the BatteryProfile wrapper returned to the controller."""
def test_to_limits_carries_metadata(self):
config = make_limits(
chemistry="LFP",
nominal_voltage_v=102.4,
max_charge_power_w=5000.0,
max_discharge_power_w=4500.0,
technical_min_soc_percent=5.0,
technical_max_soc_percent=95.0,
)
limits = config.to_limits()
assert isinstance(limits, BatteryProfile)
assert limits.battery_model == "Test Battery"
assert limits.chemistry == "LFP"
assert limits.nominal_voltage_v == pytest.approx(102.4)
assert limits.max_charge_power_w == pytest.approx(5000.0)
assert limits.max_discharge_power_w == pytest.approx(4500.0)
assert limits.technical_min_soc_percent == pytest.approx(5.0)
assert limits.technical_max_soc_percent == pytest.approx(95.0)
assert limits.limit_unit == "C-rate"
def test_accessors_delegate_to_precomputed_maps(self):
axes = Axes(temperature_degC=[0, 10], soc_percent=[0, 100])
config = make_limits(
charge_values=make_grid(2, 2, 0.3),
discharge_values=make_grid(2, 2, 0.9),
axes=axes,
)
limits = config.to_limits()
assert limits.max_charge_crate(5.0, 50.0) == pytest.approx(0.3)
assert limits.max_discharge_crate(5.0, 50.0) == pytest.approx(0.9)
def test_accessors_return_none_outside_cutoffs(self):
limits = make_limits().to_limits()
assert limits.max_charge_crate(-40.0, 50.0) is None
assert limits.max_discharge_crate(80.0, 50.0) is None
def test_accessors_match_reference_implementation(self):
axes = Axes(
temperature_degC=[-10, 0, 10, 25, 40],
soc_percent=[0, 20, 50, 80, 100],
)
values = [
[0.0, 0.0, 0.0, 0.0, 0.0],
[0.1, 0.2, 0.3, 0.2, 0.05],
[0.4, 0.5, 0.6, 0.4, 0.1],
[0.5, 0.6, 0.7, 0.5, 0.15],
[0.3, 0.4, 0.5, 0.3, 0.1],
]
config = make_limits(
charge_values=values,
discharge_values=values,
axes=axes,
)
limits = config.to_limits()
for t in axes.temperature_degC:
for s in axes.soc_percent:
ref_charge = config.max_charge_crate(t, s)
ref_discharge = config.max_discharge_crate(t, s)
if ref_charge is None:
assert limits.max_charge_crate(t, s) is None
else:
assert limits.max_charge_crate(t, s) == pytest.approx(
ref_charge, abs=1e-9
)
if ref_discharge is None:
assert limits.max_discharge_crate(t, s) is None
else:
assert limits.max_discharge_crate(t, s) == pytest.approx(
ref_discharge, abs=1e-9
)
# --- JSON file entry point ----------------------------------------------
class TestFromJsonFile:
"""Tests for the BatteryProfileSettings.from_json_file entry point."""
def test_loads_valid_document(self, tmp_path: Path):
path = tmp_path / "limits.json"
path.write_text(json.dumps(make_document()), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(path)
assert isinstance(limits, BatteryProfile)
assert limits.battery_model == "Test Battery"
assert limits.chemistry == "LFP"
def test_accepts_string_path(self, tmp_path: Path):
path = tmp_path / "limits.json"
path.write_text(json.dumps(make_document()), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(str(path))
assert isinstance(limits, BatteryProfile)
def test_missing_file_raises(self, tmp_path: Path):
with pytest.raises(FileNotFoundError):
BatteryProfileSettings.from_json_file(tmp_path / "missing.json")
def test_invalid_json_raises(self, tmp_path: Path):
path = tmp_path / "limits.json"
path.write_text("not json", encoding="utf-8")
with pytest.raises(ValidationError):
BatteryProfileSettings.from_json_file(path)
def test_invalid_document_raises(self, tmp_path: Path):
doc = make_document()
# Shape mismatch: 3 rows for a 10-entry temperature axis
doc["charge_limit_crate"]["values"] = make_grid(3, 10)
path = tmp_path / "limits.json"
path.write_text(json.dumps(doc), encoding="utf-8")
with pytest.raises(ValidationError, match="charge_limit_crate"):
BatteryProfileSettings.from_json_file(path)
def test_custom_steps_are_respected(self, tmp_path: Path):
path = tmp_path / "limits.json"
path.write_text(json.dumps(make_document()), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(path, t_step=5.0, s_step=10.0)
assert limits.charge_map.t_step == pytest.approx(5.0)
assert limits.charge_map.s_step == pytest.approx(10.0)
def test_loaded_limits_answer_queries(self, tmp_path: Path):
doc = make_document(charge_values=make_grid(value=0.42))
path = tmp_path / "limits.json"
path.write_text(json.dumps(doc), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(path)
assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.42)
def test_loaded_efficiency_curves_are_precomputed(self, tmp_path: Path):
doc = make_document(
charge_efficiency={
"c_rate": [0.1, 0.5],
"efficiency": [0.9, 0.96],
},
discharge_efficiency={
"c_rate": [0.1, 0.5],
"efficiency": [0.91, 0.95],
},
)
path = tmp_path / "battery-profile.json"
path.write_text(json.dumps(doc), encoding="utf-8")
profile = BatteryProfileSettings.from_json_file(path)
assert isinstance(profile.charge_efficiency, PrecomputedEfficiencyCurve)
assert isinstance(profile.discharge_efficiency, PrecomputedEfficiencyCurve)
assert profile.charge_efficiency_at(0.3) == pytest.approx(0.93)
assert profile.discharge_efficiency_at(0.3) == pytest.approx(0.93)
def test_loaded_cutoffs_are_honored(self, tmp_path: Path):
path = tmp_path / "limits.json"
path.write_text(json.dumps(make_document()), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(path)
# Below the charge cutoff but inside the discharge cutoff
assert limits.max_charge_crate(-12.0, 50.0) is None
assert limits.max_discharge_crate(-12.0, 50.0) is not None
def test_round_trip_from_model(self, tmp_path: Path):
original = make_limits(charge_values=make_grid(value=0.25))
path = tmp_path / "limits.json"
path.write_text(original.model_dump_json(), encoding="utf-8")
limits = BatteryProfileSettings.from_json_file(path)
assert limits.max_charge_crate(25.0, 50.0) == pytest.approx(0.25)
# --- Integration test --------------------------------------------------
class TestFromBatteryModel:
"""Tests for the BatteryProfileSettings.from_profile_name entry point.
These tests exercise the real package data directory and the real
generic-lfp.json file that ships with it. They are integration tests of
name resolution plus the JSON-loading and precompute_charge_discharge_limit_map path.
"""
def test_loads_generic_lfp(self):
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
assert isinstance(limits, BatteryProfile)
# The shipped file must at least declare a model name and a unit.
assert limits.battery_model
assert limits.limit_unit
def test_generic_lfp_answers_queries(self):
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
# Inside the precomputed grid, both accessors must return a number.
assert limits.max_charge_crate(20.0, 50.0) is not None
assert limits.max_discharge_crate(20.0, 50.0) is not None
def test_generic_lfp_charge_prohibited_below_zero(self):
# The generic-lfp file sets the charge hard cutoff at 0 degC.
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
assert limits.max_charge_crate(-1.0, 50.0) is None
def test_generic_lfp_discharge_allowed_between_cutoffs(self):
# Discharge is allowed from -10 degC to 60 degC.
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
assert limits.max_discharge_crate(-11.0, 50.0) is None
assert limits.max_discharge_crate(20.0, 50.0) is not None
assert limits.max_discharge_crate(61.0, 50.0) is None
def test_generic_lfp_has_efficiency_curves(self):
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
assert limits.charge_efficiency_at(0.25) == pytest.approx(0.97)
assert limits.discharge_efficiency_at(0.25) == pytest.approx(0.97)
def test_generic_lfp_efficiency_falls_with_c_rate(self):
# DC pack efficiency: resistive losses grow with current.
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
charge = [limits.charge_efficiency_at(c) or 0.0 for c in (0.05, 0.25, 0.5, 1.0)]
discharge = [limits.discharge_efficiency_at(c) or 0.0 for c in (0.05, 0.25, 0.5)]
assert charge == sorted(charge, reverse=True)
assert discharge == sorted(discharge, reverse=True)
assert all(0.9 < e <= 1.0 for e in charge + discharge)
def test_generic_lfp_efficiency_is_clamped_beyond_curve(self):
limits = BatteryProfileSettings.from_profile_name("generic-lfp")
assert limits.charge_efficiency_at(2.0) == pytest.approx(0.94)
assert limits.discharge_efficiency_at(1.0) == pytest.approx(0.96)
def test_missing_model_raises_filenotfound(self):
with pytest.raises(FileNotFoundError, match="does-not-exist"):
BatteryProfileSettings.from_profile_name("does-not-exist")
def test_error_message_names_expected_path(self):
with pytest.raises(FileNotFoundError, match=".json"):
BatteryProfileSettings.from_profile_name("does-not-exist")
def test_custom_steps_are_respected(self):
limits = BatteryProfileSettings.from_profile_name(
"generic-lfp", t_step=5.0, s_step=10.0
)
assert limits.charge_map.t_step == pytest.approx(5.0)
assert limits.charge_map.s_step == pytest.approx(10.0)
def test_returns_same_metadata_as_from_json_file(self):
# Both entry points must produce an equivalent BatteryProfile for the
# same file; this guards against a divergence in name resolution.
from_model = BatteryProfileSettings.from_profile_name("generic-lfp")
profile_path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp")
assert exists
from_path = BatteryProfileSettings.from_json_file(profile_path)
assert from_model.battery_model == from_path.battery_model
assert from_model.chemistry == from_path.chemistry
assert from_model.limit_unit == from_path.limit_unit
# --- nominal capacity -----------------------------------------------------
class TestNominalCapacity:
"""Tests for the optional nominal_capacity_wh of a battery profile."""
def test_defaults_to_none(self):
assert make_limits().nominal_capacity_wh is None
assert make_limits().to_limits().nominal_capacity_wh is None
def test_is_carried_into_the_compiled_profile(self):
profile = make_limits(nominal_capacity_wh=10240.0).to_limits()
assert profile.nominal_capacity_wh == pytest.approx(10240.0)
@pytest.mark.parametrize("value", [0.0, -1.0, float("nan"), float("inf")])
def test_invalid_values_are_rejected(self, value):
with pytest.raises(ValidationError):
make_limits(nominal_capacity_wh=value)
def test_survives_json_round_trip(self):
original = make_limits(nominal_capacity_wh=5120.0)
restored = BatteryProfileSettings.model_validate_json(original.model_dump_json())
assert restored.nominal_capacity_wh == pytest.approx(5120.0)
def test_documents_without_capacity_stay_valid(self):
document = json.loads(make_limits().model_dump_json())
del document["nominal_capacity_wh"]
assert BatteryProfileSettings.model_validate(document).nominal_capacity_wh is None
def test_schema_describes_the_field(self):
props = BatteryProfileSettings.model_json_schema()["properties"]
assert "description" in props["nominal_capacity_wh"]
# --- profile store --------------------------------------------------------
class TestProfileStore:
"""Tests for battery profiles in the profile store: lookup, saving, caching."""
@pytest.fixture(autouse=True)
def _empty_cache(self, config_eos):
get_profile_store().clear_cache()
yield
get_profile_store().clear_cache()
def test_packaged_profile_is_found(self):
path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp")
assert exists
assert path == get_profile_store().package_dir(BatteryProfileSettings) / "generic-lfp.json"
assert path.parent.name == "battery"
def test_missing_profile_points_to_user_dir(self):
path, exists = get_profile_store().path(BatteryProfileSettings, "does-not-exist")
assert not exists
assert path == get_profile_store().user_dir(BatteryProfileSettings) / "does-not-exist.json"
def test_empty_name_raises(self):
with pytest.raises(ValueError, match="Invalid battery profile name"):
get_profile_store().path(BatteryProfileSettings, "")
def test_user_profile_overrides_packaged_profile(self):
user_path = make_limits(battery_model="generic-lfp").save_profile("generic-lfp")
path, exists = get_profile_store().path(BatteryProfileSettings, "generic-lfp")
assert exists
assert path == user_path
assert get_profile_store().load(BatteryProfileSettings, "generic-lfp").battery_model == (
"generic-lfp"
)
def test_save_and_load_round_trip(self):
original = make_limits(battery_model="my-battery", charge_values=make_grid(value=0.3))
path = original.save_profile("my-battery")
assert path == get_profile_store().user_dir(BatteryProfileSettings) / "my-battery.json"
assert BatteryProfileSettings.load_profile("my-battery").model_dump() == original.model_dump()
def test_model_name_is_free_text(self):
"""The profile name, not the model name, is the file name."""
document = make_limits(battery_model="BYD Battery-Box Premium HVS 7.7")
path = document.save_profile("byd-hvs-7.7")
assert path.name == "byd-hvs-7.7.json"
loaded = BatteryProfileSettings.load_profile("byd-hvs-7.7")
assert loaded.battery_model == "BYD Battery-Box Premium HVS 7.7"
def test_load_missing_profile_names_expected_path(self):
with pytest.raises(FileNotFoundError, match=r"No battery profile.*does-not-exist\.json"):
BatteryProfileSettings.load_profile("does-not-exist")
with pytest.raises(FileNotFoundError, match="does-not-exist.json"):
get_profile_store().get(BatteryProfileSettings, "does-not-exist")
def test_compiled_profile_is_cached(self):
first = get_profile_store().get(BatteryProfileSettings, "generic-lfp")
second = BatteryProfileSettings.from_profile_name("generic-lfp")
assert first is second
# Other precomputation steps are a different compiled profile.
coarse = get_profile_store().get(BatteryProfileSettings, "generic-lfp", t_step=5.0)
assert coarse is not first
assert coarse.charge_map.t_step == pytest.approx(5.0)
assert BatteryProfileSettings.from_profile_name("generic-lfp", t_step=5.0) is coarse
def test_unknown_compile_option_raises(self):
with pytest.raises(TypeError, match="Unknown battery profile options"):
get_profile_store().get(BatteryProfileSettings, "generic-lfp", u_step=1.0)
def test_document_is_a_battery_profile_type(self):
assert BatteryProfileSettings.profile_kind == "battery"
assert isinstance(make_limits().compile_profile(), BatteryProfile)
def test_changed_file_is_recompiled(self):
make_limits(battery_model="edited", charge_values=make_grid(value=0.2)).save_profile("edited")
before = get_profile_store().get(BatteryProfileSettings, "edited")
assert before.max_charge_crate(25.0, 50.0) == pytest.approx(0.2)
# A different size changes the cache key even if mtime has coarse resolution.
make_limits(
battery_model="edited",
charge_values=make_grid(value=0.25),
chemistry="LFP",
).save_profile("edited")
after = get_profile_store().get(BatteryProfileSettings, "edited")
assert after is not before
assert after.max_charge_crate(25.0, 50.0) == pytest.approx(0.25)
def test_compiled_grids_are_read_only(self):
profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp")
with pytest.raises(ValueError):
profile.charge_map.grid[0, 0] = 1.0
# --- JSON schema ---------------------------------------------------------
class TestJsonSchema:
"""Tests that the generated JSON schema carries descriptions and examples."""
def test_control_battery_limits_schema(self):
schema = BatteryProfileSettings.model_json_schema()
props = schema["properties"]
assert "description" in props["interpolation"]
assert props["interpolation"]["examples"] == ["bilinear"]
assert "description" in props["hard_cutoffs"]
assert "description" in props["battery_model"]
def test_axes_schema_examples(self):
schema = Axes.model_json_schema()
assert schema["properties"]["temperature_degC"]["examples"]
assert schema["properties"]["soc_percent"]["examples"]
# --- Round-trip ----------------------------------------------------------
class TestRoundTrip:
"""Tests that a config survives a JSON round-trip unchanged."""
def test_round_trip_preserves_efficiency_curves(self):
original = make_limits(
charge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.9, 0.96]
),
discharge_efficiency=make_efficiency_curve(
c_rate=[0.1, 0.5], efficiency=[0.91, 0.95],
interpolation=EfficiencyInterpolation.NEAREST,
out_of_range_behavior=OutOfRangeBehavior.REJECT,
),
)
restored = BatteryProfileSettings.model_validate_json(
original.model_dump_json()
)
assert restored.model_dump() == original.model_dump()
assert restored.charge_efficiency is not None
assert restored.discharge_efficiency is not None
assert restored.charge_efficiency.lookup(0.3) == pytest.approx(0.93)
assert restored.discharge_efficiency.lookup(0.3) == pytest.approx(0.91)
def test_round_trip_preserves_values(self):
original = make_limits(charge_values=make_grid(value=0.25))
dumped = original.model_dump_json()
restored = BatteryProfileSettings.model_validate_json(dumped)
assert restored.model_dump() == original.model_dump()
assert restored.max_charge_crate(25.0, 50.0) == pytest.approx(0.25)
# --- Strict profile documents --------------------------------------------
class TestStrictProfile:
"""A profile contains every value the computation uses, and nothing unknown."""
@pytest.mark.parametrize(
"field",
[
"technical_min_soc_percent",
"technical_max_soc_percent",
"charge_efficiency",
"discharge_efficiency",
"units",
"axes",
"hard_cutoffs",
"charge_limit_crate",
"discharge_limit_crate",
],
)
def test_required_fields(self, field):
document = make_document()
del document[field]
with pytest.raises(ValidationError, match=field):
BatteryProfileSettings.model_validate(document)
@pytest.mark.parametrize(
"field",
["nominal_capacity_wh", "max_charge_power_w", "max_discharge_power_w", "chemistry"],
)
def test_optional_fields(self, field):
document = make_document()
document.pop(field, None)
assert getattr(BatteryProfileSettings.model_validate(document), field) is None
def test_unknown_top_level_key_is_rejected(self):
# A typo of nominal_capacity_wh must not be ignored silently.
document = make_document(nominal_capacity_kwh=10.0)
with pytest.raises(ValidationError, match="nominal_capacity_kwh"):
BatteryProfileSettings.model_validate(document)
@pytest.mark.parametrize("section", ["units", "axes", "hard_cutoffs", "charge_efficiency"])
def test_unknown_nested_key_is_rejected(self, section):
document = make_document()
document[section] = {**document[section], "unknown_key": 1}
with pytest.raises(ValidationError, match="unknown_key"):
BatteryProfileSettings.model_validate(document)
@pytest.mark.parametrize("section", ["charge_efficiency", "hard_cutoffs"])
def test_sections_take_a_description(self, section):
document = make_document()
document[section] = {**document[section], "description": "Data sheet values"}
profile = BatteryProfileSettings.model_validate(document)
assert getattr(profile, section).description == "Data sheet values"
def test_technical_soc_bounds_must_be_ordered(self):
document = make_document(technical_min_soc_percent=90.0, technical_max_soc_percent=10.0)
with pytest.raises(ValidationError, match="technical_min_soc_percent"):
BatteryProfileSettings.model_validate(document)
def test_bundled_generic_profile_is_valid_and_generic(self, config_eos):
path = (
config_eos.package_root_path / "data" / "profile" / "battery" / "generic-lfp.json"
)
profile = BatteryProfileSettings.model_validate(json.loads(path.read_text()))
# Generic: C-rate limits, no capacity of its own.
assert profile.nominal_capacity_wh is None
assert profile.units.limit == "C-rate"
assert profile.technical_min_soc_percent == 0.0
assert profile.technical_max_soc_percent == 100.0