Files
EOS/tests/test_geneticvisualize.py
T

243 lines
11 KiB
Python
Raw Normal View History

from io import BytesIO
2024-11-10 23:22:30 +01:00
from pathlib import Path
from types import SimpleNamespace
2024-11-10 23:22:30 +01:00
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
import pendulum
import pytest
2024-11-10 23:22:30 +01:00
from matplotlib.testing.compare import compare_images
from pypdf import PdfReader
2024-11-10 23:22:30 +01:00
from akkudoktoreos.optimization.genetic import geneticvisualize as visualize
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
2026-08-01 12:45:19 +02:00
from akkudoktoreos.optimization.genetic.geneticvisualize import (
genetic_generate_example_report,
)
from akkudoktoreos.optimization.genetic.terminalvalue import (
TailDiagnostics,
TailPlanSlot,
TerminalValueCurve,
TerminalValueResult,
)
2024-12-24 13:10:31 +01:00
filename = "example_report.pdf"
2024-11-10 23:22:30 +01:00
2026-08-01 12:45:19 +02:00
DIR_TESTDATA = Path(__file__).parent / "testdata" / "genetic"
2024-12-24 13:10:31 +01:00
reference_file = DIR_TESTDATA / "test_example_report.pdf"
2026-08-01 12:45:19 +02:00
reference_png_file = DIR_TESTDATA / "test_example_report_pdf.png"
2026-07-15 16:38:53 +02:00
output_file = DIR_TESTDATA / "test_example_report_new.pdf"
2026-08-01 12:45:19 +02:00
output_png_file = DIR_TESTDATA / "test_example_report_new_pdf.png"
2024-11-10 23:22:30 +01:00
def test_generate_pdf_example(config_eos):
2024-12-29 18:42:49 +01:00
"""Test generation of example visualization report."""
# Generate PDF
2026-08-01 12:45:19 +02:00
genetic_generate_example_report(filename=str(output_file))
2024-11-11 21:38:13 +01:00
2024-12-24 13:10:31 +01:00
# Check if the file exists
assert output_file.exists()
2024-12-24 13:10:31 +01:00
# 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}"
2026-08-01 12:45:19 +02:00
# 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.