mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 17:21:57 +00:00
Regression tests were scattered across feature- and bug-specific files, and some suites repeated the same checks. Group tests by responsibility, isolate feature fixtures in test classes, and remove duplicate or obsolete cases. The review covered all 127 collected test modules on main. This reduces them and removes or combines definitions:. No application code changes.
243 lines
11 KiB
Python
243 lines
11 KiB
Python
from io import BytesIO
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import matplotlib.dates as mdates
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import pendulum
|
|
import pytest
|
|
from matplotlib.testing.compare import compare_images
|
|
from pypdf import PdfReader
|
|
|
|
from akkudoktoreos.optimization.genetic import geneticvisualize as visualize
|
|
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
|
|
from akkudoktoreos.optimization.genetic.geneticvisualize import (
|
|
genetic_generate_example_report,
|
|
)
|
|
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
|
TailDiagnostics,
|
|
TailPlanSlot,
|
|
TerminalValueCurve,
|
|
TerminalValueResult,
|
|
)
|
|
|
|
filename = "example_report.pdf"
|
|
|
|
|
|
DIR_TESTDATA = Path(__file__).parent / "testdata" / "genetic"
|
|
reference_file = DIR_TESTDATA / "test_example_report.pdf"
|
|
reference_png_file = DIR_TESTDATA / "test_example_report_pdf.png"
|
|
output_file = DIR_TESTDATA / "test_example_report_new.pdf"
|
|
output_png_file = DIR_TESTDATA / "test_example_report_new_pdf.png"
|
|
|
|
|
|
def test_generate_pdf_example(config_eos):
|
|
"""Test generation of example visualization report."""
|
|
# Generate PDF
|
|
genetic_generate_example_report(filename=str(output_file))
|
|
|
|
# Check if the file exists
|
|
assert output_file.exists()
|
|
|
|
# Compare the generated file with the reference file
|
|
comparison = compare_images(str(reference_file), str(output_file), tol=0)
|
|
|
|
# Assert that there are no differences
|
|
assert comparison is None, f"Images differ: {comparison}"
|
|
|
|
# Everything passed, remove compare file
|
|
reference_png_file.unlink()
|
|
output_file.unlink()
|
|
output_png_file.unlink()
|
|
|
|
|
|
class TestGeneticVisualizationIntervals:
|
|
@pytest.fixture
|
|
def report_solution(self):
|
|
start = pendulum.datetime(2026, 10, 25, 1, 45, tz="Europe/Berlin")
|
|
energy = [100.0, 150.0, 200.0, 250.0]
|
|
result = SimpleNamespace(
|
|
load_wh_per_hour=energy,
|
|
home_appliance_wh_per_hour=energy,
|
|
grid_feed_in_wh_per_hour=energy,
|
|
grid_consumption_wh_per_hour=energy,
|
|
losses_per_hour=[1.0] * 4,
|
|
battery_soc_per_hour=[20.0, 25.0, 30.0, 35.0],
|
|
ev_soc_per_hour=[0.0] * 4,
|
|
costs_per_hour=[0.03] * 4,
|
|
revenue_per_hour=[0.01] * 4,
|
|
total_costs=0.12,
|
|
total_revenue=0.04,
|
|
total_balance=0.08,
|
|
home_appliance_energy_wh={"water-heater": energy},
|
|
)
|
|
return SimpleNamespace(
|
|
parameters=SimpleNamespace(
|
|
ems=SimpleNamespace(
|
|
total_load=energy,
|
|
pv_forecast_wh=energy,
|
|
feed_in_tariff_per_wh=[0.00007] * 4,
|
|
electricity_price_per_wh=[0.0003] * 4,
|
|
),
|
|
temperature_forecast=[12.0] * 4,
|
|
),
|
|
interval_seconds=900,
|
|
start_solution_datetime=start,
|
|
controls_start_at_now=True,
|
|
start_hour=1,
|
|
ac_charge=[0.0, 0.25, 0.5, 1.0],
|
|
dc_charge=[1.0] * 4,
|
|
discharge_allowed=[0, 0, 1, 1],
|
|
battery_grid_export_allowed=[0, 0, 0, 1],
|
|
battery_grid_export_factor=[0.0, 0.0, 0.0, 0.5],
|
|
result=result,
|
|
extra_data=None,
|
|
fitness_history=None,
|
|
fixed_seed=None,
|
|
appliance_starts={"water-heater": [start.add(minutes=15)]},
|
|
appliance_deadline_missed={"water-heater": True},
|
|
terminal_value=TerminalValueResult(
|
|
mode="TAIL", control_horizon_hours=1, requested_tail_hours=2,
|
|
effective_tail_hours=0.25, tail_end_hour=1.25,
|
|
battery_energy_wh=500, credited_euro=0.13,
|
|
tail_operating_euro=0.1, continuation_value_euro=0.03,
|
|
continuation_mode="AUTO", reason="forecast gap limits tail",
|
|
curve=TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.26]),
|
|
tail_diagnostics=TailDiagnostics(slots=1, slot_hours=0.25),
|
|
tail_plan=[TailPlanSlot(
|
|
slot=0, hour_from_start=1, action="discharge",
|
|
soc_start_percentage=40, soc_end_percentage=35,
|
|
pv_wh=0, load_wh=100, grid_import_wh=0, grid_export_wh=0,
|
|
battery_charge_wh=0, battery_discharge_wh=100,
|
|
import_price_euro_per_kwh=0.3, feed_in_tariff_euro_per_kwh=0.07,
|
|
slot_value_euro=0.03, remaining_value_euro=0.1,
|
|
ac_charge_factor=0, dc_charge_allowed=1, discharge_allowed=1,
|
|
battery_grid_export_factor=0,
|
|
)],
|
|
),
|
|
)
|
|
|
|
@pytest.mark.parametrize("interval_seconds", [900, 3600])
|
|
def test_report_preserves_run_timing_energy_and_export_controls(self,
|
|
config_eos, monkeypatch, report_solution, interval_seconds
|
|
):
|
|
report_solution.interval_seconds = interval_seconds
|
|
captured = []
|
|
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
|
|
|
def capture(self, start_date, y_list, **kwargs):
|
|
captured.append((start_date, y_list, kwargs, self.interval_seconds))
|
|
return original(self, start_date, y_list, **kwargs)
|
|
|
|
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
|
monkeypatch.setattr(visualize, "get_ems", lambda: pytest.fail("Snapshot must own report timing"))
|
|
pdf = visualize.genetic_prepare_visualize(report_solution)
|
|
assert pdf.startswith(b"%PDF-")
|
|
assert all(row[0] == report_solution.start_solution_datetime for row in captured)
|
|
assert all(row[3] == interval_seconds for row in captured)
|
|
load = next(row for row in captured if row[2].get("title") == "Load Profile")
|
|
assert load[1] == [[100, 150, 200, 250]] # No repeated wall-clock trimming or Wh scaling.
|
|
controls = next(row for row in captured if row[2].get("title") == "Executable Battery Controls")
|
|
assert controls[1][-1] == [0, 0, 0, 0.5]
|
|
assert len(controls[1]) == 5
|
|
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
|
for expected in (
|
|
"Energy Flow per Interval", "water-heater", "Deadline missed: True",
|
|
"forecast gap limits tail", "effective tail: 0.25 h", "0.13 EUR",
|
|
"not executable controls", "not realized revenue", "Residual Battery Value Curve",
|
|
):
|
|
assert expected in text
|
|
assert "Energy Flow per Hour" not in text
|
|
|
|
def test_short_quarterhour_chart_has_elapsed_fractional_hours(self, config_eos):
|
|
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
|
start = pendulum.datetime(2026, 10, 25, 2, 45, tz="Europe/Berlin", fold=0)
|
|
report.create_line_chart_date(start, [[100.0, 200.0, 300.0, 400.0]], ylabel="Wh")
|
|
fig, axis = plt.subplots()
|
|
try:
|
|
report.current_group[0]()
|
|
dates = mdates.num2date(axis.lines[0].get_xdata())
|
|
assert np.diff([value.timestamp() for value in dates]).tolist() == [900] * 3
|
|
assert np.asarray(axis.lines[0].get_ydata()).tolist() == [100, 200, 300, 400]
|
|
assert [label.get_text() for label in fig.axes[1].get_xticklabels()] == ["0", "0.25", "0.5", "0.75"]
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
def test_empty_and_mismatched_date_series(self, config_eos):
|
|
report = visualize.GeneticVisualizationReport()
|
|
start = pendulum.now("UTC")
|
|
report.create_line_chart_date(start, [[]], ylabel="Wh")
|
|
assert report.current_group == []
|
|
with pytest.raises(ValueError, match="same intervals"):
|
|
report.create_line_chart_date(start, [[1.0, 2.0], [1.0]], ylabel="Wh")
|
|
|
|
def test_existing_hourly_solution_renders_without_terminal_metadata(self, config_eos, monkeypatch):
|
|
payload = (Path(__file__).parent / "testdata/genetic/optimize_result_1.json").read_text()
|
|
solution = GeneticSolution.model_validate_json(payload)
|
|
assert not solution.controls_start_at_now
|
|
assert solution.terminal_value is None
|
|
start = pendulum.datetime(2026, 9, 16, solution.start_hour, tz="UTC")
|
|
monkeypatch.setattr(visualize, "get_ems", lambda: SimpleNamespace(start_datetime=start))
|
|
pdf = visualize.genetic_prepare_visualize(solution)
|
|
assert len(PdfReader(BytesIO(pdf)).pages) >= 4
|
|
|
|
def test_fixed_fallback_does_not_invent_tail_charts(self, config_eos, report_solution):
|
|
report_solution.terminal_value = TerminalValueResult(
|
|
mode="FIXED", reason="No complete tail forecast", credited_euro=0.05,
|
|
battery_energy_wh=500, requested_tail_hours=24,
|
|
)
|
|
report_solution.result.home_appliance_energy_wh = {}
|
|
pdf = visualize.genetic_prepare_visualize(report_solution)
|
|
text = " ".join(page.extract_text() for page in PdfReader(BytesIO(pdf)).pages)
|
|
assert "No complete tail forecast" in text
|
|
assert "effective tail: 0 h" in text
|
|
assert "Deadline missed: True" in text # Report unscheduled consumers even with no energy series.
|
|
assert "Residual Battery Value Curve" not in text
|
|
assert "Tail Lookahead:" not in text
|
|
|
|
def test_tail_chart_starts_after_control_horizon(self, config_eos, report_solution):
|
|
report = visualize.GeneticVisualizationReport(interval_seconds=900)
|
|
start = report_solution.start_solution_datetime
|
|
visualize._add_solution_diagnostics(report, report_solution, start)
|
|
fig, axis = plt.subplots()
|
|
try:
|
|
report.groups[-1][0]()
|
|
expected = mdates.date2num(start.add(hours=1))
|
|
assert np.asarray(axis.lines[0].get_xdata()).tolist() == [expected]
|
|
assert np.asarray(axis.lines[3].get_ydata()).tolist() == [100]
|
|
assert all(line.get_marker() == "o" for line in axis.lines)
|
|
left, right = axis.get_xlim()
|
|
assert left < expected < right
|
|
assert (right - left) * 86400 == pytest.approx(900)
|
|
assert "Not Executable" in axis.get_title()
|
|
assert report_solution.discharge_allowed == [0, 0, 1, 1]
|
|
finally:
|
|
plt.close(fig)
|
|
|
|
@pytest.mark.parametrize("tariff", [0.00007, [0.00007] * 6])
|
|
def test_pdf_clips_forecast_tail_and_accepts_historical_diagnostics(self,
|
|
config_eos, monkeypatch, report_solution, tariff
|
|
):
|
|
report_solution.parameters.ems.total_load = [100.0] * 6
|
|
report_solution.parameters.ems.pv_forecast_wh = [200.0] * 6
|
|
report_solution.parameters.ems.feed_in_tariff_per_wh = tariff
|
|
report_solution.extra_data = {
|
|
"verluste": [1.0, 2.0, 3.0], "bilanz": [0.1, 0.2, 0.3],
|
|
"nebenbedingung": [0.0, 0.0, 0.0],
|
|
}
|
|
captured = []
|
|
original = visualize.GeneticVisualizationReport.create_line_chart_date
|
|
|
|
def capture(self, start_date, y_list, **kwargs):
|
|
captured.append((y_list, kwargs))
|
|
return original(self, start_date, y_list, **kwargs)
|
|
|
|
monkeypatch.setattr(visualize.GeneticVisualizationReport, "create_line_chart_date", capture)
|
|
pdf = visualize.genetic_prepare_visualize(report_solution)
|
|
assert pdf.startswith(b"%PDF-")
|
|
tariff_plot = next(series for series, kwargs in captured if kwargs.get("title") == "Remuneration")
|
|
assert np.asarray(tariff_plot[0]).tolist() == [0.00007] * 4
|
|
assert all(len(series[0]) == 4 for series, _ in captured)
|
|
assert "verluste" in report_solution.extra_data # Rendering must not mutate the retained run.
|