Fix2 electricity price prediction. (#296)

Normalize electricity price prediction to €/Wh.
Provide electricity price prediction by €/kWh for convenience.

Allow to configure electricity price charges by €/kWh.

Also added error page to fastapi rest server to get rid of annoying
unrelated fault messages during testing.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte 2024-12-30 21:29:50 +01:00 committed by GitHub
parent 45079ca29c
commit c0ea13d0f4
13 changed files with 178 additions and 52 deletions

View File

@ -16,6 +16,7 @@ services:
- latitude=52.2 - latitude=52.2
- longitude=13.4 - longitude=13.4
- elecprice_provider=ElecPriceAkkudoktor - elecprice_provider=ElecPriceAkkudoktor
- elecprice_charges=0.21 - elecprice_charges_kwh=0.21
- server_fasthtml_host=none
ports: ports:
- "${EOS_PORT}:${EOS_PORT}" - "${EOS_PORT}:${EOS_PORT}"

View File

@ -669,7 +669,7 @@
"/strompreis": { "/strompreis": {
"get": { "get": {
"summary": "Fastapi Strompreis", "summary": "Fastapi Strompreis",
"description": "Deprecated: Electricity Market Price Prediction.\n\nNote:\n Use '/v1/prediction/list?key=elecprice_marketprice' instead.", "description": "Deprecated: Electricity Market Price Prediction per Wh (\u20ac/Wh).\n\nNote:\n Use '/v1/prediction/list?key=elecprice_marketprice_wh' or\n '/v1/prediction/list?key=elecprice_marketprice_kwh' instead.",
"operationId": "fastapi_strompreis_strompreis_get", "operationId": "fastapi_strompreis_strompreis_get",
"responses": { "responses": {
"200": { "200": {
@ -2498,7 +2498,7 @@
"title": "Elecprice Provider", "title": "Elecprice Provider",
"description": "Electricity price provider id of provider to be used." "description": "Electricity price provider id of provider to be used."
}, },
"elecprice_charges": { "elecprice_charges_kwh": {
"anyOf": [ "anyOf": [
{ {
"type": "number", "type": "number",
@ -2508,7 +2508,7 @@
"type": "null" "type": "null"
} }
], ],
"title": "Elecprice Charges", "title": "Elecprice Charges Kwh",
"description": "Electricity price charges (\u20ac/kWh)." "description": "Electricity price charges (\u20ac/kWh)."
}, },
"prediction_hours": { "prediction_hours": {
@ -5169,7 +5169,7 @@
"title": "Elecprice Provider", "title": "Elecprice Provider",
"description": "Electricity price provider id of provider to be used." "description": "Electricity price provider id of provider to be used."
}, },
"elecprice_charges": { "elecprice_charges_kwh": {
"anyOf": [ "anyOf": [
{ {
"type": "number", "type": "number",
@ -5179,7 +5179,7 @@
"type": "null" "type": "null"
} }
], ],
"title": "Elecprice Charges", "title": "Elecprice Charges Kwh",
"description": "Electricity price charges (\u20ac/kWh)." "description": "Electricity price charges (\u20ac/kWh)."
}, },
"prediction_hours": { "prediction_hours": {

View File

@ -2,7 +2,7 @@ numpy==2.2.0
numpydantic==1.6.4 numpydantic==1.6.4
matplotlib==3.10.0 matplotlib==3.10.0
fastapi[standard]==0.115.6 fastapi[standard]==0.115.6
python-fasthtml==0.9.1 python-fasthtml==0.10.3
uvicorn==0.34.0 uvicorn==0.34.0
scikit-learn==1.6.0 scikit-learn==1.6.0
timezonefinder==6.5.7 timezonefinder==6.5.7

View File

@ -94,12 +94,11 @@ def prepare_optimization_real_parameters() -> OptimizationParameters:
print(f"temperature_forecast: {temperature_forecast}") print(f"temperature_forecast: {temperature_forecast}")
# Electricity Price (in Euro per Wh) # Electricity Price (in Euro per Wh)
electricity_market_price_euros_per_kwh = prediction_eos.key_to_array( strompreis_euro_pro_wh = prediction_eos.key_to_array(
key="elecprice_marketprice", key="elecprice_marketprice_wh",
start_datetime=prediction_eos.start_datetime, start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime, end_datetime=prediction_eos.end_datetime,
) )
strompreis_euro_pro_wh = electricity_market_price_euros_per_kwh * 0.001
print(f"strompreis_euro_pro_wh: {strompreis_euro_pro_wh}") print(f"strompreis_euro_pro_wh: {strompreis_euro_pro_wh}")
# Overall System Load (in W) # Overall System Load (in W)

View File

@ -6,7 +6,7 @@
"data_folder_path": null, "data_folder_path": null,
"data_output_path": null, "data_output_path": null,
"data_output_subpath": null, "data_output_subpath": null,
"elecprice_charges": 0.21, "elecprice_charges_kwh": 0.21,
"elecprice_provider": null, "elecprice_provider": null,
"elecpriceimport_file_path": null, "elecpriceimport_file_path": null,
"latitude": 52.5, "latitude": 52.5,
@ -102,6 +102,7 @@
"pvforecast4_userhorizon": null, "pvforecast4_userhorizon": null,
"pvforecast_provider": null, "pvforecast_provider": null,
"pvforecastimport_file_path": null, "pvforecastimport_file_path": null,
"server_fastapi_startup_server_fasthtml": true,
"server_fastapi_host": "0.0.0.0", "server_fastapi_host": "0.0.0.0",
"server_fastapi_port": 8503, "server_fastapi_port": 8503,
"server_fasthtml_host": "0.0.0.0", "server_fasthtml_host": "0.0.0.0",

View File

@ -211,8 +211,8 @@ class Devices(SingletonMixin, DevicesBase):
interval=simulation_step, interval=simulation_step,
) )
# strompreis_euro_pro_wh[stunde] # strompreis_euro_pro_wh[stunde]
elecprice_marketprice = self.prediction.key_to_array( elecprice_marketprice_wh = self.prediction.key_to_array(
"elecprice_marketprice", "elecprice_marketprice_wh",
start_datetime=self.start_datetime, start_datetime=self.start_datetime,
end_datetime=self.end_datetime, end_datetime=self.end_datetime,
interval=simulation_step, interval=simulation_step,

View File

@ -9,6 +9,6 @@ class ElecPriceCommonSettings(SettingsBaseModel):
elecprice_provider: Optional[str] = Field( elecprice_provider: Optional[str] = Field(
default=None, description="Electricity price provider id of provider to be used." default=None, description="Electricity price provider id of provider to be used."
) )
elecprice_charges: Optional[float] = Field( elecprice_charges_kwh: Optional[float] = Field(
default=None, ge=0, description="Electricity price charges (€/kWh)." default=None, ge=0, description="Electricity price charges (€/kWh)."
) )

View File

@ -7,7 +7,7 @@ Notes:
from abc import abstractmethod from abc import abstractmethod
from typing import List, Optional from typing import List, Optional
from pydantic import Field from pydantic import Field, computed_field
from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord
from akkudoktoreos.utils.logutil import get_logger from akkudoktoreos.utils.logutil import get_logger
@ -23,10 +23,22 @@ class ElecPriceDataRecord(PredictionRecord):
""" """
elecprice_marketprice: Optional[float] = Field( elecprice_marketprice_wh: Optional[float] = Field(
None, description="Electricity market price (€/KWh)" None, description="Electricity market price per Wh (€/Wh)"
) )
# Computed fields
@computed_field # type: ignore[prop-decorator]
@property
def elecprice_marketprice_kwh(self) -> Optional[float]:
"""Electricity market price per kWh (€/kWh).
Convenience attribute calculated from `elecprice_marketprice_wh`.
"""
if self.elecprice_marketprice_wh is None:
return None
return self.elecprice_marketprice_wh * 1000.0
class ElecPriceProvider(PredictionProvider): class ElecPriceProvider(PredictionProvider):
"""Abstract base class for electricity price providers. """Abstract base class for electricity price providers.

View File

@ -134,7 +134,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
prices_of_hour = self.elecprice_8days[hour] prices_of_hour = self.elecprice_8days[hour]
if np.isnan(prices_of_hour).all(): if np.isnan(prices_of_hour).all():
# No prediction prices available for this hour - use mean value of all prices # No prediction prices available for this hour - use mean value of all prices
price_weighted_mean = np.nanmean(self.elecprice_marketprice_8day) price_weighted_mean = np.nanmean(self.elecprice_marketprice_wh_8day)
else: else:
weights = self.elecprice_8days_weights_day_of_week[day_of_week] weights = self.elecprice_8days_weights_day_of_week[day_of_week]
prices_of_hour_masked: NDArray[Shape["24"]] = np.ma.MaskedArray( prices_of_hour_masked: NDArray[Shape["24"]] = np.ma.MaskedArray(
@ -204,24 +204,28 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
elecprice_cache_file.seek(0) elecprice_cache_file.seek(0)
self.elecprice_8days = np.load(elecprice_cache_file) self.elecprice_8days = np.load(elecprice_cache_file)
# Get elecprice_charges # Get elecprice_charges_kwh_kwh
charges = self.config.elecprice_charges if self.config.elecprice_charges else 0.0 charges_kwh = (
self.config.elecprice_charges_kwh if self.config.elecprice_charges_kwh else 0.0
)
for i in range(values_len): for i in range(values_len):
original_datetime = akkudoktor_data.values[i].start original_datetime = akkudoktor_data.values[i].start
dt = to_datetime(original_datetime, in_timezone=self.config.timezone) dt = to_datetime(original_datetime, in_timezone=self.config.timezone)
akkudoktor_value = akkudoktor_data.values[i] akkudoktor_value = akkudoktor_data.values[i]
price = akkudoktor_value.marketpriceEurocentPerKWh / 100 + charges price_wh = (
akkudoktor_value.marketpriceEurocentPerKWh / (100 * 1000) + charges_kwh / 1000
)
if compare_datetimes(dt, self.start_datetime).lt: if compare_datetimes(dt, self.start_datetime).lt:
# forecast data is too old # forecast data is too old
self.elecprice_8days[dt.hour, dt.day_of_week] = price self.elecprice_8days[dt.hour, dt.day_of_week] = price_wh
continue continue
self.elecprice_8days[dt.hour, 7] = price self.elecprice_8days[dt.hour, 7] = price_wh
record = ElecPriceDataRecord( record = ElecPriceDataRecord(
date_time=dt, date_time=dt,
elecprice_marketprice=price, elecprice_marketprice_wh=price_wh,
) )
self.append(record) self.append(record)
@ -242,7 +246,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
record = ElecPriceDataRecord( record = ElecPriceDataRecord(
date_time=dt, date_time=dt,
elecprice_marketprice=value, elecprice_marketprice_wh=value,
) )
self.insert(0, record) self.insert(0, record)
# Assure price ends at end_time # Assure price ends at end_time
@ -253,6 +257,6 @@ class ElecPriceAkkudoktor(ElecPriceProvider):
record = ElecPriceDataRecord( record = ElecPriceDataRecord(
date_time=dt, date_time=dt,
elecprice_marketprice=value, elecprice_marketprice_wh=value,
) )
self.append(record) self.append(record)

View File

@ -11,7 +11,7 @@ import pandas as pd
import uvicorn import uvicorn
from fastapi import FastAPI, Query, Request from fastapi import FastAPI, Query, Request
from fastapi.exceptions import HTTPException from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, RedirectResponse, Response from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config
from akkudoktoreos.core.ems import get_ems from akkudoktoreos.core.ems import get_ems
@ -37,6 +37,98 @@ measurement_eos = get_measurement()
prediction_eos = get_prediction() prediction_eos = get_prediction()
ems_eos = get_ems() ems_eos = get_ems()
ERROR_PAGE_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Energy Optimization System (EOS) Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.error-container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 100%;
text-align: center;
}
.error-code {
font-size: 4rem;
font-weight: bold;
color: #e53e3e;
margin: 0;
}
.error-title {
font-size: 1.5rem;
color: #2d3748;
margin: 1rem 0;
}
.error-message {
color: #4a5568;
margin-bottom: 1.5rem;
}
.error-details {
background: #f7fafc;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
text-align: left;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
}
.back-button {
background: #3182ce;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
transition: background-color 0.2s;
}
.back-button:hover {
background: #2c5282;
}
</style>
</head>
<body>
<div class="error-container">
<h1 class="error-code">STATUS_CODE</h1>
<h2 class="error-title">ERROR_TITLE</h2>
<p class="error-message">ERROR_MESSAGE</p>
<div class="error-details">ERROR_DETAILS</div>
<a href="/docs" class="back-button">Back to Home</a>
</div>
</body>
</html>
"""
def create_error_page(
status_code: str, error_title: str, error_message: str, error_details: str
) -> str:
"""Create an error page by replacing placeholders in the template."""
return (
ERROR_PAGE_TEMPLATE.replace("STATUS_CODE", status_code)
.replace("ERROR_TITLE", error_title)
.replace("ERROR_MESSAGE", error_message)
.replace("ERROR_DETAILS", error_details)
)
def start_fasthtml_server() -> subprocess.Popen: def start_fasthtml_server() -> subprocess.Popen:
"""Start the fasthtml server as a subprocess.""" """Start the fasthtml server as a subprocess."""
@ -301,10 +393,11 @@ def fastapi_prediction_list_get(
@app.get("/strompreis") @app.get("/strompreis")
def fastapi_strompreis() -> list[float]: def fastapi_strompreis() -> list[float]:
"""Deprecated: Electricity Market Price Prediction. """Deprecated: Electricity Market Price Prediction per Wh (€/Wh).
Note: Note:
Use '/v1/prediction/list?key=elecprice_marketprice' instead. Use '/v1/prediction/list?key=elecprice_marketprice_wh' or
'/v1/prediction/list?key=elecprice_marketprice_kwh' instead.
""" """
settings = SettingsEOS( settings = SettingsEOS(
elecprice_provider="ElecPriceAkkudoktor", elecprice_provider="ElecPriceAkkudoktor",
@ -318,7 +411,7 @@ def fastapi_strompreis() -> list[float]:
# Get the current date and the end date based on prediction hours # Get the current date and the end date based on prediction hours
# Fetch prices for the specified date range # Fetch prices for the specified date range
return prediction_eos.key_to_array( return prediction_eos.key_to_array(
key="elecprice_marketprice", key="elecprice_marketprice_wh",
start_datetime=prediction_eos.start_datetime, start_datetime=prediction_eos.start_datetime,
end_datetime=prediction_eos.end_datetime, end_datetime=prediction_eos.end_datetime,
).tolist() ).tolist()
@ -513,7 +606,7 @@ async def proxy_put(request: Request, path: str) -> Response:
return await proxy(request, path) return await proxy(request, path)
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse]: async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse | HTMLResponse]:
if config_eos.server_fasthtml_host and config_eos.server_fasthtml_port: if config_eos.server_fasthtml_host and config_eos.server_fasthtml_port:
# Proxy to fasthtml server # Proxy to fasthtml server
url = f"http://{config_eos.server_fasthtml_host}:{config_eos.server_fasthtml_port}/{path}" url = f"http://{config_eos.server_fasthtml_host}:{config_eos.server_fasthtml_port}/{path}"
@ -521,15 +614,31 @@ async def proxy(request: Request, path: str) -> Union[Response | RedirectRespons
data = await request.body() data = await request.body()
async with httpx.AsyncClient() as client: try:
if request.method == "GET": async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers) if request.method == "GET":
elif request.method == "POST": response = await client.get(url, headers=headers)
response = await client.post(url, headers=headers, content=data) elif request.method == "POST":
elif request.method == "PUT": response = await client.post(url, headers=headers, content=data)
response = await client.put(url, headers=headers, content=data) elif request.method == "PUT":
elif request.method == "DELETE": response = await client.put(url, headers=headers, content=data)
response = await client.delete(url, headers=headers, content=data) elif request.method == "DELETE":
response = await client.delete(url, headers=headers, content=data)
except Exception as e:
error_page = create_error_page(
status_code="404",
error_title="Page Not Found",
error_message=f"""<pre>
Application server not reachable: '{url}'
Did you start the application server
or set 'server_fastapi_startup_server_fasthtml'?
If there is no application server intended please
set 'server_fasthtml_host' or 'server_fasthtml_port' to None.
</pre>
""",
error_details=f"{e}",
)
return HTMLResponse(content=error_page, status_code=404)
return Response( return Response(
content=response.content, content=response.content,

View File

@ -144,7 +144,7 @@ def test_update_data(mock_get, elecprice_provider, sample_akkudoktor_1_json, cac
# Assert we get prediction_hours prioce values by resampling # Assert we get prediction_hours prioce values by resampling
np_price_array = elecprice_provider.key_to_array( np_price_array = elecprice_provider.key_to_array(
key="elecprice_marketprice", key="elecprice_marketprice_wh",
start_datetime=elecprice_provider.start_datetime, start_datetime=elecprice_provider.start_datetime,
end_datetime=elecprice_provider.end_datetime, end_datetime=elecprice_provider.end_datetime,
) )
@ -203,7 +203,7 @@ def test_key_to_array_resampling(elecprice_provider):
"""Test resampling of forecast data to NumPy array.""" """Test resampling of forecast data to NumPy array."""
elecprice_provider.update_data(force_update=True) elecprice_provider.update_data(force_update=True)
array = elecprice_provider.key_to_array( array = elecprice_provider.key_to_array(
key="elecprice_marketprice", key="elecprice_marketprice_wh",
start_datetime=elecprice_provider.start_datetime, start_datetime=elecprice_provider.start_datetime,
end_datetime=elecprice_provider.end_datetime, end_datetime=elecprice_provider.end_datetime,
) )

View File

@ -92,7 +92,7 @@ def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_f
assert elecprice_provider.start_datetime is not None assert elecprice_provider.start_datetime is not None
assert elecprice_provider.total_hours is not None assert elecprice_provider.total_hours is not None
assert compare_datetimes(elecprice_provider.start_datetime, ems_eos.start_datetime).equal assert compare_datetimes(elecprice_provider.start_datetime, ems_eos.start_datetime).equal
values = sample_import_1_json["elecprice_marketprice"] values = sample_import_1_json["elecprice_marketprice_wh"]
value_datetime_mapping = elecprice_provider.import_datetimes( value_datetime_mapping = elecprice_provider.import_datetimes(
ems_eos.start_datetime, len(values) ems_eos.start_datetime, len(values)
) )
@ -101,7 +101,7 @@ def test_import(elecprice_provider, sample_import_1_json, start_datetime, from_f
expected_datetime, expected_value_index = mapping expected_datetime, expected_value_index = mapping
expected_value = values[expected_value_index] expected_value = values[expected_value_index]
result_datetime = elecprice_provider.records[i].date_time result_datetime = elecprice_provider.records[i].date_time
result_value = elecprice_provider.records[i]["elecprice_marketprice"] result_value = elecprice_provider.records[i]["elecprice_marketprice_wh"]
# print(f"{i}: Expected: {expected_datetime}:{expected_value}") # print(f"{i}: Expected: {expected_datetime}:{expected_value}")
# print(f"{i}: Result: {result_datetime}:{result_value}") # print(f"{i}: Result: {result_datetime}:{result_value}")

View File

@ -6,14 +6,14 @@
488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67, 488.89, 506.91, 804.89, 1141.98, 1056.97, 992.46, 1155.99, 827.01, 1257.98, 1232.67,
871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97 871.26, 860.88, 1158.03, 1222.72, 1221.04, 949.99, 987.01, 733.99, 592.97
], ],
"elecprice_marketprice": [ "elecprice_marketprice_wh": [
0.3384, 0.3318, 0.3284, 0.3283, 0.3289, 0.3334, 0.3290, 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290,
0.3302, 0.3042, 0.2430, 0.2280, 0.2212, 0.2093, 0.1879, 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879,
0.1838, 0.2004, 0.2198, 0.2270, 0.2997, 0.3195, 0.3081, 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081,
0.2969, 0.2921, 0.2780, 0.3384, 0.3318, 0.3284, 0.3283, 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283,
0.3289, 0.3334, 0.3290, 0.3302, 0.3042, 0.2430, 0.2280, 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280,
0.2212, 0.2093, 0.1879, 0.1838, 0.2004, 0.2198, 0.2270, 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270,
0.2997, 0.3195, 0.3081, 0.2969, 0.2921, 0.2780 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780
], ],
"pvforecast_ac_power": [ "pvforecast_ac_power": [
0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69, 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69,