mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 23:46:38 +00:00
218 lines
9.6 KiB
Python
218 lines
9.6 KiB
Python
"""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.
|