Files
EOS/tests/test_geneticvisualize_intervals.py
T

218 lines
9.6 KiB
Python
Raw Normal View History

"""Semantic report tests with synthetic data; no forecast provider or live EMS run."""
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 pypdf import PdfReader
from akkudoktoreos.optimization.genetic import geneticvisualize as visualize
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.genetic.terminalvalue import (
TailDiagnostics,
TailPlanSlot,
TerminalValueCurve,
TerminalValueResult,
)
@pytest.fixture
def report_solution():
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(
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(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(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(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(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(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(
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.