mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
fix: preserve imported feed-in revenue during parameter preparation
Cancel GENETIC preparation when imported revenue cannot be read or contains invalid values, preserving the chosen provider instead of replacing it with demo tariffs. Keep valid positive, zero and negative amount/Wh series unchanged. Adapt the revenue-preservation regressions from PRs #1224 and #1304 to the async main API, including real timestamped imports and simulation repricing. The feature-only direct-marketing override remains outside this main fix. Co-authored-by: Christin <info@bikinibottom.capital> Co-authored-by: Normann <github@koldrack.com>
This commit is contained in:
co-authored by
Christin
Normann
parent
4a3724424f
commit
b2a4e2f457
@@ -10,6 +10,7 @@ forecasts, and fallback defaults, preparing them for optimization runs.
|
|||||||
|
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AliasChoices,
|
AliasChoices,
|
||||||
@@ -516,8 +517,29 @@ class GeneticOptimizationParameters(
|
|||||||
interval=interval,
|
interval=interval,
|
||||||
fill_method="ffill",
|
fill_method="ffill",
|
||||||
)
|
)
|
||||||
|
# Prediction records already contain amount/Wh; only fixed-provider
|
||||||
|
# configuration is expressed in amount/kWh. Preserve signs and units.
|
||||||
|
if cls.config.feedintariff.provider == "FeedInTariffImport":
|
||||||
|
if array.ndim != 1 or len(array) != len(pvforecast_ac_power):
|
||||||
|
raise ValueError(
|
||||||
|
"Imported feed-in tariff length does not match the forecast horizon"
|
||||||
|
)
|
||||||
|
if not np.isfinite(array).all():
|
||||||
|
raise ValueError(
|
||||||
|
"Imported feed-in tariff contains missing or non-finite values"
|
||||||
|
)
|
||||||
feed_in_tariff_wh = array.tolist()
|
feed_in_tariff_wh = array.tolist()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if cls.config.feedintariff.provider == "FeedInTariffImport":
|
||||||
|
# An external EMS supplies the resolved sale revenue. Replacing
|
||||||
|
# missing imports with demo or purchase prices changes the economics.
|
||||||
|
logger.error(
|
||||||
|
"Cannot prepare GENETIC parameters: FeedInTariffImport revenue is "
|
||||||
|
"unavailable or invalid; keeping the configured provider and "
|
||||||
|
"canceling optimization: {}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
logger.info(
|
logger.info(
|
||||||
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
|
||||||
attempt,
|
attempt,
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Keep imported sale revenues separate from purchase prices in main's async GENETIC path.
|
||||||
|
|
||||||
|
Adapted from PRs #1224 (Christin) and #1304 (Normann). Main has no direct-marketing
|
||||||
|
parameter override yet: these regressions cover its existing preparation/simulation
|
||||||
|
contract and refuse unavailable imported revenue instead of creating a demo tariff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||||
|
from akkudoktoreos.optimization.genetic.genetic import GeneticSimulation
|
||||||
|
from akkudoktoreos.optimization.genetic.geneticdevices import InverterParameters
|
||||||
|
from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters
|
||||||
|
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffDataRecord
|
||||||
|
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||||
|
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prepare_tariffs(config_eos):
|
||||||
|
"""Run real async preparation with deterministic forecasts and no device fallback."""
|
||||||
|
|
||||||
|
async def prepare(provider, revenues, tariff_reader=None):
|
||||||
|
config_eos.merge_settings_from_dict(
|
||||||
|
{
|
||||||
|
"prediction": {"hours": 24},
|
||||||
|
"optimization": {"genetic": {"horizon_hours": 24, "interval_sec": 3600}},
|
||||||
|
"feedintariff": {"provider": provider},
|
||||||
|
"elecfee": {"provider": None},
|
||||||
|
"devices": {
|
||||||
|
"max_batteries": 0,
|
||||||
|
"max_electric_vehicles": 0,
|
||||||
|
"max_inverters": 0,
|
||||||
|
"max_home_appliances": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
prices = np.array([0.000269, -0.00002] * 12)
|
||||||
|
arrays = {
|
||||||
|
"weather_temp_air": np.full(24, 20.0),
|
||||||
|
"pvforecast_ac_power": np.full(24, 1000.0),
|
||||||
|
"loadforecast_power_w": np.zeros(24),
|
||||||
|
"elecprice_marketprice_wh": prices,
|
||||||
|
"feed_in_tariff_wh": revenues,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def read_array(key, **kwargs):
|
||||||
|
if key == "feed_in_tariff_wh" and tariff_reader is not None:
|
||||||
|
return await tariff_reader(key=key, **kwargs)
|
||||||
|
value = arrays[key]
|
||||||
|
if isinstance(value, Exception):
|
||||||
|
raise value
|
||||||
|
return np.asarray(value)
|
||||||
|
|
||||||
|
prediction = Mock(update_data=AsyncMock(), key_to_array=AsyncMock(side_effect=read_array))
|
||||||
|
ems = Mock(start_datetime=to_datetime("2026-08-01T00:00:00+00:00"))
|
||||||
|
ems.genetic_solution.return_value = None
|
||||||
|
with (
|
||||||
|
patch("akkudoktoreos.optimization.genetic.geneticparams.get_ems", return_value=ems),
|
||||||
|
patch.object(GeneticOptimizationParameters, "prediction", prediction),
|
||||||
|
):
|
||||||
|
parameters = await GeneticOptimizationParameters.prepare()
|
||||||
|
return parameters, prices, prediction
|
||||||
|
|
||||||
|
return prepare
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"provider",
|
||||||
|
[
|
||||||
|
"FeedInTariffImport",
|
||||||
|
"FeedInTariffAkkudoktor",
|
||||||
|
"FeedInTariffEnergyCharts",
|
||||||
|
"FeedInTariffTibber",
|
||||||
|
"FeedInTariffFixed",
|
||||||
|
"FeedInTariffSMARD",
|
||||||
|
"FeedInTariffDvhubOnline",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("revenues", [[0.00007], [0.0], [-0.00005], [0.000184, -0.00005]])
|
||||||
|
async def test_provider_revenues_survive_preparation_and_simulation(
|
||||||
|
prepare_tariffs, provider, revenues, config_eos
|
||||||
|
):
|
||||||
|
expected = (revenues * 24)[:24]
|
||||||
|
parameters, prices, prediction = await prepare_tariffs(provider, expected)
|
||||||
|
assert parameters is not None
|
||||||
|
assert parameters.ems.feed_in_tariff_per_wh == expected
|
||||||
|
assert parameters.ems.einspeiseverguetung_euro_pro_wh == expected
|
||||||
|
assert parameters.ems.electricity_price_per_wh == prices.tolist()
|
||||||
|
assert config_eos.feedintariff.provider == provider
|
||||||
|
prediction.update_data.assert_awaited_once()
|
||||||
|
|
||||||
|
# A 1 kWh export at 0.00007 amount/Wh earns 0.07, not 70 or 0.00007.
|
||||||
|
# No battery or household load is needed to expose tariff substitution/unit bugs.
|
||||||
|
inverter = Inverter(InverterParameters(device_id="inverter1", max_power_wh=10000))
|
||||||
|
inverter.self_consumption_predictor = Mock()
|
||||||
|
inverter.self_consumption_predictor.calculate_self_consumption.return_value = 1.0
|
||||||
|
simulation = GeneticSimulation()
|
||||||
|
simulation.prepare(
|
||||||
|
parameters.ems, optimization_hours=24, prediction_hours=24, inverter=inverter
|
||||||
|
)
|
||||||
|
result = simulation.simulate(start_hour=0)
|
||||||
|
assert result["Netzeinspeisung_Wh_pro_Stunde"] == pytest.approx([1000.0] * 24)
|
||||||
|
assert result["Einnahmen_Euro_pro_Stunde"] == pytest.approx(np.array(expected) * 1000)
|
||||||
|
assert result["Gesamteinnahmen_Euro"] == pytest.approx(sum(expected) * 1000)
|
||||||
|
assert result["Gesamtbilanz_Euro"] == pytest.approx(-sum(expected) * 1000)
|
||||||
|
assert parameters.ems.feed_in_tariff_per_wh == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"revenues",
|
||||||
|
[
|
||||||
|
KeyError("feed_in_tariff_wh"),
|
||||||
|
RuntimeError("import unavailable"),
|
||||||
|
[],
|
||||||
|
[0.00007] * 23,
|
||||||
|
[0.00007] * 25,
|
||||||
|
[np.nan] * 24,
|
||||||
|
[0.00007] * 23 + [np.nan],
|
||||||
|
[np.inf] * 24,
|
||||||
|
[-np.inf] * 24,
|
||||||
|
[None] * 24,
|
||||||
|
[[0.00007]] * 24,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_import_cancels_without_replacing_provider(
|
||||||
|
prepare_tariffs, revenues, config_eos, caplog
|
||||||
|
):
|
||||||
|
parameters, _, prediction = await prepare_tariffs("FeedInTariffImport", revenues)
|
||||||
|
assert parameters is None
|
||||||
|
assert config_eos.feedintariff.provider == "FeedInTariffImport"
|
||||||
|
prediction.update_data.assert_awaited_once()
|
||||||
|
assert "canceling optimization" in caplog.text
|
||||||
|
assert "FeedInTariffImport" in caplog.text
|
||||||
|
assert "defaulting to demo" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_prediction_record_prices_are_already_per_wh():
|
||||||
|
record = FeedInTariffDataRecord(feed_in_tariff_wh=0.00007)
|
||||||
|
assert record.feed_in_tariff_kwh == pytest.approx(0.07)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("values", [[0.000184, -0.00005] * 12, [0.0] * 24, [None] * 24])
|
||||||
|
async def test_timestamped_import_records_are_read_in_order(prepare_tariffs, values):
|
||||||
|
provider = FeedInTariffImport()
|
||||||
|
provider._db_reset_state()
|
||||||
|
start = to_datetime("2026-08-01T00:00:00+00:00").set(hour=0)
|
||||||
|
try:
|
||||||
|
await provider.key_from_series(
|
||||||
|
"feed_in_tariff_wh",
|
||||||
|
pd.Series(values, index=pd.date_range(start=start, periods=24, freq="h")),
|
||||||
|
)
|
||||||
|
parameters, _, _ = await prepare_tariffs(
|
||||||
|
"FeedInTariffImport", [], tariff_reader=provider.key_to_array
|
||||||
|
)
|
||||||
|
if values[0] is None:
|
||||||
|
assert parameters is None
|
||||||
|
else:
|
||||||
|
assert parameters is not None
|
||||||
|
assert parameters.ems.feed_in_tariff_per_wh == pytest.approx(values)
|
||||||
|
finally:
|
||||||
|
provider._db_reset_state()
|
||||||
Reference in New Issue
Block a user