mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
318 lines
15 KiB
Python
318 lines
15 KiB
Python
"""Tests for the inverter device settings resolved from inverter profiles."""
|
|||
|
|
|
||
|
|
import dataclasses
|
||
|
|
import math
|
||
|
|
from typing import Any, Optional
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from akkudoktoreos.devices.profile.inverterprofile import (
|
||
|
|
InverterProfile,
|
||
|
|
InverterProfileSettings,
|
||
|
|
)
|
||
|
|
from akkudoktoreos.devices.profile.profilecurve import PrecomputedCurve
|
||
|
|
from akkudoktoreos.devices.profile.profilestore import get_profile_store
|
||
|
|
from akkudoktoreos.devices.settings import invertermeasurement
|
||
|
|
from akkudoktoreos.devices.settings.inverterresolution import (
|
||
|
|
INVERTER_DEFAULT_TEMPERATURE_DEGC,
|
||
|
|
InverterOperatingPoint,
|
||
|
|
InverterResolutionError,
|
||
|
|
)
|
||
|
|
from akkudoktoreos.devices.settings.invertersettings import (
|
||
|
|
INVERTER_DEFAULT_PROFILE,
|
||
|
|
InverterCommonSettings,
|
||
|
|
InverterProfileAdjustment,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def make_settings(**adjustment: Any) -> InverterCommonSettings:
|
||
|
|
return InverterCommonSettings(
|
||
|
|
device_id="inverter1",
|
||
|
|
battery_id="battery1",
|
||
|
|
profile_adjustment=InverterProfileAdjustment(**adjustment),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def generic(config_eos) -> InverterProfile:
|
||
|
|
"""The packaged generic hybrid inverter profile."""
|
||
|
|
return get_profile_store().get(InverterProfileSettings, "generic-hybrid")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def use_profile(monkeypatch):
|
||
|
|
"""Let all inverter settings use the given compiled profile."""
|
||
|
|
|
||
|
|
def _use(profile: InverterProfile) -> None:
|
||
|
|
monkeypatch.setattr(InverterCommonSettings, "get_inverter_profile", lambda self: profile)
|
||
|
|
|
||
|
|
return _use
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def measured(monkeypatch) -> dict[str, Optional[float]]:
|
||
|
|
"""Measured values by key for the inverter measurement access."""
|
||
|
|
values: dict[str, Optional[float]] = {}
|
||
|
|
|
||
|
|
async def fake_get_measured_value(key: str, target_datetime: Any) -> Optional[float]:
|
||
|
|
return values.get(key)
|
||
|
|
|
||
|
|
monkeypatch.setattr(invertermeasurement, "get_measured_value", fake_get_measured_value)
|
||
|
|
return values
|
||
|
|
|
||
|
|
|
||
|
|
class TestDefaults:
|
||
|
|
def test_default_profile(self):
|
||
|
|
settings = InverterCommonSettings(device_id="inverter1")
|
||
|
|
assert settings.inverter_profile == INVERTER_DEFAULT_PROFILE == "generic-hybrid"
|
||
|
|
assert settings.profile_adjustment.model_dump() == InverterProfileAdjustment().model_dump()
|
||
|
|
|
||
|
|
def test_generic_profile_needs_a_nominal_power(self, generic):
|
||
|
|
settings = InverterCommonSettings(device_id="inverter1")
|
||
|
|
with pytest.raises(InverterResolutionError, match="inverter1.*nominal_power_w"):
|
||
|
|
settings.resolve_operating_parameters()
|
||
|
|
assert settings.effective_nominal_power_w is None
|
||
|
|
assert settings.effective_max_power_w is None
|
||
|
|
errors, warnings = settings.check_profile()
|
||
|
|
assert len(errors) == 1 and "inverter1" in errors[0]
|
||
|
|
assert warnings == []
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
"adjustment",
|
||
|
|
[{"nominal_power_w": 0}, {"max_power_w": 0}, {"max_ac_charge_power_w": -1}],
|
||
|
|
)
|
||
|
|
def test_rejects_invalid_adjustment(self, adjustment):
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
InverterProfileAdjustment(**adjustment)
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolution:
|
||
|
|
def test_reference_point_is_45_degc(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
parameters = settings.resolve_operating_parameters()
|
||
|
|
assert parameters.temperature_degC == INVERTER_DEFAULT_TEMPERATURE_DEGC == 45.0
|
||
|
|
# The generic profile is not derated at 45 degC.
|
||
|
|
assert parameters.power_fraction == pytest.approx(1.0)
|
||
|
|
assert parameters.nominal_power_w == pytest.approx(10000)
|
||
|
|
assert parameters.max_power_w == pytest.approx(10000)
|
||
|
|
assert parameters.max_ac_charge_power_w == pytest.approx(10000)
|
||
|
|
assert settings.effective_max_power_w == pytest.approx(10000)
|
||
|
|
|
||
|
|
def test_efficiencies_come_from_the_profile(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
parameters = settings.resolve_operating_parameters()
|
||
|
|
# DC→AC at the reference load fraction 0.06 of the maximum power.
|
||
|
|
assert parameters.dc_to_ac_efficiency == pytest.approx(generic.dc_to_ac_efficiency_at(0.06))
|
||
|
|
# AC→DC at the AC charge limit, here the full nominal power.
|
||
|
|
assert parameters.ac_to_dc_efficiency == pytest.approx(generic.ac_to_dc_efficiency_at(1.0))
|
||
|
|
assert settings.effective_dc_to_ac_efficiency == pytest.approx(
|
||
|
|
parameters.dc_to_ac_efficiency
|
||
|
|
)
|
||
|
|
assert settings.effective_ac_to_dc_efficiency == pytest.approx(
|
||
|
|
parameters.ac_to_dc_efficiency
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_derating_at_the_operating_point(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
|
||
|
|
assert parameters.power_fraction == pytest.approx(0.6)
|
||
|
|
assert parameters.max_power_w == pytest.approx(6000)
|
||
|
|
assert parameters.max_ac_charge_power_w == pytest.approx(6000)
|
||
|
|
|
||
|
|
def test_curves_are_over_the_load_fraction_of_the_maximum_power(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
|
||
|
|
# 3000 W are 0.3 of the nominal and 0.5 of the derated maximum power.
|
||
|
|
curve = PrecomputedCurve.from_points(
|
||
|
|
[x for x, _ in parameters.dc_to_ac_efficiency_curve],
|
||
|
|
[y for _, y in parameters.dc_to_ac_efficiency_curve],
|
||
|
|
)
|
||
|
|
assert curve.lookup(0.5) == pytest.approx(generic.dc_to_ac_efficiency_at(0.3))
|
||
|
|
# The curve ends at the maximum power, 0.6 of the nominal power.
|
||
|
|
assert parameters.dc_to_ac_efficiency_curve[-1] == pytest.approx(
|
||
|
|
(1.0, generic.dc_to_ac_efficiency_at(0.6))
|
||
|
|
)
|
||
|
|
assert all(0.0 <= x <= 1.0 for x, _ in parameters.dc_to_ac_efficiency_curve)
|
||
|
|
|
||
|
|
def test_curve_keeps_two_points_when_heavily_limited(self, generic):
|
||
|
|
# 400 W are below the first breakpoint (0.05 of 10 kW = 500 W).
|
||
|
|
settings = make_settings(nominal_power_w=10000, max_power_w=400)
|
||
|
|
curve = settings.resolve_operating_parameters().dc_to_ac_efficiency_curve
|
||
|
|
assert [x for x, _ in curve] == [0.0, 1.0]
|
||
|
|
assert [y for _, y in curve] == pytest.approx([0.88, 0.88])
|
||
|
|
settings.to_genetic_param()
|
||
|
|
|
||
|
|
def test_no_power_at_the_operating_point(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
with pytest.raises(InverterResolutionError, match="allows no power"):
|
||
|
|
settings.resolve_operating_parameters(InverterOperatingPoint(70.0))
|
||
|
|
|
||
|
|
def test_installation_limits_only_lower(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000, max_power_w=8000, max_ac_charge_power_w=3000)
|
||
|
|
parameters = settings.resolve_operating_parameters()
|
||
|
|
assert parameters.max_power_w == pytest.approx(8000)
|
||
|
|
assert parameters.max_ac_charge_power_w == pytest.approx(3000)
|
||
|
|
assert parameters.ac_to_dc_efficiency == pytest.approx(generic.ac_to_dc_efficiency_at(0.3))
|
||
|
|
assert settings.check_profile() == ([], [])
|
||
|
|
|
||
|
|
def test_derating_below_the_installation_limit(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000, max_power_w=8000)
|
||
|
|
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
|
||
|
|
assert parameters.max_power_w == pytest.approx(6000)
|
||
|
|
|
||
|
|
def test_zero_ac_charge_limit_disables_ac_charging(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000, max_ac_charge_power_w=0)
|
||
|
|
parameters = settings.resolve_operating_parameters()
|
||
|
|
assert parameters.max_ac_charge_power_w == 0
|
||
|
|
assert math.isfinite(parameters.ac_to_dc_efficiency)
|
||
|
|
assert settings.to_genetic_param().max_ac_charge_power_w == 0
|
||
|
|
|
||
|
|
def test_model_ac_charge_limit_is_derated(self, generic, use_profile):
|
||
|
|
use_profile(dataclasses.replace(generic, nominal_power_w=10000, max_ac_charge_power_w=5000))
|
||
|
|
settings = make_settings()
|
||
|
|
assert settings.resolve_operating_parameters().max_ac_charge_power_w == pytest.approx(5000)
|
||
|
|
derated = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
|
||
|
|
assert derated.max_ac_charge_power_w == pytest.approx(3000)
|
||
|
|
|
||
|
|
def test_profile_nominal_power_and_adjustment(self, generic, use_profile):
|
||
|
|
use_profile(dataclasses.replace(generic, nominal_power_w=6000))
|
||
|
|
assert make_settings().effective_nominal_power_w == pytest.approx(6000)
|
||
|
|
assert make_settings(nominal_power_w=8000).effective_nominal_power_w == pytest.approx(8000)
|
||
|
|
|
||
|
|
def test_missing_profile(self, config_eos):
|
||
|
|
settings = InverterCommonSettings(
|
||
|
|
device_id="inverter1",
|
||
|
|
inverter_profile="does-not-exist",
|
||
|
|
profile_adjustment=InverterProfileAdjustment(nominal_power_w=10000),
|
||
|
|
)
|
||
|
|
with pytest.raises(InverterResolutionError, match="does-not-exist.*not usable"):
|
||
|
|
settings.resolve_operating_parameters()
|
||
|
|
assert settings.effective_max_power_w is None
|
||
|
|
errors, _ = settings.check_profile()
|
||
|
|
assert "does-not-exist" in errors[0]
|
||
|
|
|
||
|
|
def test_warns_about_limits_without_effect(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=5000, max_power_w=8000, max_ac_charge_power_w=6000)
|
||
|
|
errors, warnings = settings.check_profile()
|
||
|
|
assert errors == []
|
||
|
|
assert len(warnings) == 2
|
||
|
|
assert "profile_adjustment.max_power_w" in warnings[0]
|
||
|
|
assert "profile_adjustment.max_ac_charge_power_w" in warnings[1]
|
||
|
|
|
||
|
|
|
||
|
|
class TestMeasurement:
|
||
|
|
def test_temperature_measurement_key(self):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
assert settings.measurement_key_temperature_degc == "inverter1-temperature-degc"
|
||
|
|
assert "inverter1-temperature-degc" in settings.measurement_keys
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_measured_temperature(self, measured):
|
||
|
|
measured["inverter1-temperature-degc"] = 52.5
|
||
|
|
point = await make_settings().measure_operating_point()
|
||
|
|
assert point == InverterOperatingPoint(temperature_degC=52.5)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@pytest.mark.parametrize("value", [None, float("nan"), float("inf")])
|
||
|
|
async def test_missing_temperature_defaults_to_45_degc(self, measured, value):
|
||
|
|
measured["inverter1-temperature-degc"] = value
|
||
|
|
point = await make_settings().measure_operating_point()
|
||
|
|
assert point.temperature_degC == 45.0
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_resolution_at_the_measured_temperature(self, generic, measured):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
measured["inverter1-temperature-degc"] = 60.0
|
||
|
|
parameters = await settings.resolve_current_operating_parameters()
|
||
|
|
assert parameters.max_power_w == pytest.approx(6000)
|
||
|
|
|
||
|
|
|
||
|
|
class TestConversion:
|
||
|
|
def test_genetic_parameters(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000, max_ac_charge_power_w=4000)
|
||
|
|
parameters = settings.resolve_operating_parameters(InverterOperatingPoint(60.0))
|
||
|
|
genetic = settings.to_genetic_param(parameters)
|
||
|
|
assert genetic.device_id == "inverter1"
|
||
|
|
assert genetic.battery_id == "battery1"
|
||
|
|
assert genetic.max_power_wh == pytest.approx(6000)
|
||
|
|
assert genetic.max_ac_charge_power_w == pytest.approx(4000)
|
||
|
|
assert genetic.dc_to_ac_efficiency == pytest.approx(parameters.dc_to_ac_efficiency)
|
||
|
|
assert genetic.ac_to_dc_efficiency == pytest.approx(parameters.ac_to_dc_efficiency)
|
||
|
|
assert genetic.dc_to_ac_efficiency_curve == [
|
||
|
|
tuple(point) for point in parameters.dc_to_ac_efficiency_curve
|
||
|
|
]
|
||
|
|
assert genetic.ac_charge_limits_total_charge is False
|
||
|
|
|
||
|
|
def test_genetic0_parameters(self, generic):
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
genetic0 = settings.to_genetic0_param()
|
||
|
|
assert genetic0.max_power_wh == pytest.approx(10000)
|
||
|
|
assert genetic0.dc_to_ac_efficiency == pytest.approx(settings.effective_dc_to_ac_efficiency)
|
||
|
|
assert genetic0.ac_to_dc_efficiency == pytest.approx(settings.effective_ac_to_dc_efficiency)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def logged_errors():
|
||
|
|
"""Collect the messages logged at ERROR level."""
|
||
|
|
from loguru import logger
|
||
|
|
|
||
|
|
messages: list[str] = []
|
||
|
|
sink_id = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR")
|
||
|
|
yield messages
|
||
|
|
logger.remove(sink_id)
|
||
|
|
|
||
|
|
|
||
|
|
class TestOptimizationDefaults:
|
||
|
|
"""The parameter preparation of the optimizations never fails (defaults with an error log)."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_derated_to_zero_uses_the_reference_temperature(
|
||
|
|
self, generic, measured, logged_errors
|
||
|
|
):
|
||
|
|
"""generic-hybrid derates to 0 W at 65 degC; the run gets the 45 degC parameters."""
|
||
|
|
settings = make_settings(nominal_power_w=10000)
|
||
|
|
measured["inverter1-temperature-degc"] = 70.0
|
||
|
|
parameters = await settings.resolve_current_operating_parameters_or_defaults()
|
||
|
|
assert parameters.max_power_w == pytest.approx(10000)
|
||
|
|
assert parameters.temperature_degC == INVERTER_DEFAULT_TEMPERATURE_DEGC
|
||
|
|
assert len(logged_errors) == 1
|
||
|
|
assert "allows no power at 70.0 degC" in logged_errors[0]
|
||
|
|
# The conversion gets valid parameters.
|
||
|
|
assert settings.to_genetic_param(parameters).max_power_wh == pytest.approx(10000)
|
||
|
|
|
||
|
|
def test_missing_nominal_power_gives_the_fallback(self, generic, logged_errors):
|
||
|
|
parameters = make_settings().resolve_operating_parameters_or_defaults()
|
||
|
|
assert parameters.nominal_power_w == pytest.approx(10000)
|
||
|
|
assert len(logged_errors) == 1
|
||
|
|
assert "nominal power 10000 W" in logged_errors[0]
|
||
|
|
|
||
|
|
def test_unusable_profile_uses_the_generic_profile(self, generic, logged_errors):
|
||
|
|
settings = InverterCommonSettings(
|
||
|
|
device_id="inverter1",
|
||
|
|
battery_id="battery1",
|
||
|
|
inverter_profile="does-not-exist",
|
||
|
|
profile_adjustment=InverterProfileAdjustment(nominal_power_w=8000),
|
||
|
|
)
|
||
|
|
parameters = settings.resolve_operating_parameters_or_defaults()
|
||
|
|
assert parameters.nominal_power_w == pytest.approx(8000)
|
||
|
|
assert parameters.dc_to_ac_efficiency < 1.0 # from the generic profile curves
|
||
|
|
assert len(logged_errors) == 1
|
||
|
|
assert f"inverter profile '{INVERTER_DEFAULT_PROFILE}'" in logged_errors[0]
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("error", [FileNotFoundError, PermissionError, IsADirectoryError])
|
||
|
|
def test_without_any_usable_profile(self, monkeypatch, logged_errors, error):
|
||
|
|
def missing(document_type, name, **options):
|
||
|
|
raise error(f"Inverter profile '{name}' is not readable")
|
||
|
|
|
||
|
|
monkeypatch.setattr(get_profile_store(), "get", missing)
|
||
|
|
settings = make_settings(nominal_power_w=8000, max_power_w=6000)
|
||
|
|
parameters = settings.resolve_operating_parameters_or_defaults()
|
||
|
|
assert parameters.max_power_w == pytest.approx(6000)
|
||
|
|
assert parameters.dc_to_ac_efficiency == pytest.approx(1.0)
|
||
|
|
assert len(logged_errors) == 1
|
||
|
|
assert "constant efficiency 1" in logged_errors[0]
|
||
|
|
|
||
|
|
def test_report_false_logs_nothing(self, generic, logged_errors):
|
||
|
|
make_settings().resolve_operating_parameters_or_defaults(report=False)
|
||
|
|
assert logged_errors == []
|