diff --git a/docker-compose.yaml b/docker-compose.yaml index 26a0c97..2759ef6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,6 +16,7 @@ services: - latitude=52.2 - longitude=13.4 - elecprice_provider=ElecPriceAkkudoktor - - elecprice_charges=0.21 + - elecprice_charges_kwh=0.21 + - server_fasthtml_host=none ports: - "${EOS_PORT}:${EOS_PORT}" diff --git a/docs/akkudoktoreos/openapi.json b/docs/akkudoktoreos/openapi.json index 758858c..73492c3 100644 --- a/docs/akkudoktoreos/openapi.json +++ b/docs/akkudoktoreos/openapi.json @@ -669,7 +669,7 @@ "/strompreis": { "get": { "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", "responses": { "200": { @@ -2498,7 +2498,7 @@ "title": "Elecprice Provider", "description": "Electricity price provider id of provider to be used." }, - "elecprice_charges": { + "elecprice_charges_kwh": { "anyOf": [ { "type": "number", @@ -2508,7 +2508,7 @@ "type": "null" } ], - "title": "Elecprice Charges", + "title": "Elecprice Charges Kwh", "description": "Electricity price charges (\u20ac/kWh)." }, "prediction_hours": { @@ -5169,7 +5169,7 @@ "title": "Elecprice Provider", "description": "Electricity price provider id of provider to be used." }, - "elecprice_charges": { + "elecprice_charges_kwh": { "anyOf": [ { "type": "number", @@ -5179,7 +5179,7 @@ "type": "null" } ], - "title": "Elecprice Charges", + "title": "Elecprice Charges Kwh", "description": "Electricity price charges (\u20ac/kWh)." }, "prediction_hours": { diff --git a/requirements.txt b/requirements.txt index e42587b..ea5c3a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ numpy==2.2.0 numpydantic==1.6.4 matplotlib==3.10.0 fastapi[standard]==0.115.6 -python-fasthtml==0.9.1 +python-fasthtml==0.10.3 uvicorn==0.34.0 scikit-learn==1.6.0 timezonefinder==6.5.7 diff --git a/single_test_optimization.py b/single_test_optimization.py index 7ef2ebf..83a99d7 100644 --- a/single_test_optimization.py +++ b/single_test_optimization.py @@ -94,12 +94,11 @@ def prepare_optimization_real_parameters() -> OptimizationParameters: print(f"temperature_forecast: {temperature_forecast}") # Electricity Price (in Euro per Wh) - electricity_market_price_euros_per_kwh = prediction_eos.key_to_array( - key="elecprice_marketprice", + strompreis_euro_pro_wh = prediction_eos.key_to_array( + key="elecprice_marketprice_wh", start_datetime=prediction_eos.start_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}") # Overall System Load (in W) diff --git a/src/akkudoktoreos/data/default.config.json b/src/akkudoktoreos/data/default.config.json index 0659642..29e4a55 100644 --- a/src/akkudoktoreos/data/default.config.json +++ b/src/akkudoktoreos/data/default.config.json @@ -6,7 +6,7 @@ "data_folder_path": null, "data_output_path": null, "data_output_subpath": null, - "elecprice_charges": 0.21, + "elecprice_charges_kwh": 0.21, "elecprice_provider": null, "elecpriceimport_file_path": null, "latitude": 52.5, @@ -102,6 +102,7 @@ "pvforecast4_userhorizon": null, "pvforecast_provider": null, "pvforecastimport_file_path": null, + "server_fastapi_startup_server_fasthtml": true, "server_fastapi_host": "0.0.0.0", "server_fastapi_port": 8503, "server_fasthtml_host": "0.0.0.0", diff --git a/src/akkudoktoreos/devices/devices.py b/src/akkudoktoreos/devices/devices.py index 30e520c..87c9629 100644 --- a/src/akkudoktoreos/devices/devices.py +++ b/src/akkudoktoreos/devices/devices.py @@ -211,8 +211,8 @@ class Devices(SingletonMixin, DevicesBase): interval=simulation_step, ) # strompreis_euro_pro_wh[stunde] - elecprice_marketprice = self.prediction.key_to_array( - "elecprice_marketprice", + elecprice_marketprice_wh = self.prediction.key_to_array( + "elecprice_marketprice_wh", start_datetime=self.start_datetime, end_datetime=self.end_datetime, interval=simulation_step, diff --git a/src/akkudoktoreos/prediction/elecprice.py b/src/akkudoktoreos/prediction/elecprice.py index 1432dee..bac466f 100644 --- a/src/akkudoktoreos/prediction/elecprice.py +++ b/src/akkudoktoreos/prediction/elecprice.py @@ -9,6 +9,6 @@ class ElecPriceCommonSettings(SettingsBaseModel): elecprice_provider: Optional[str] = Field( 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)." ) diff --git a/src/akkudoktoreos/prediction/elecpriceabc.py b/src/akkudoktoreos/prediction/elecpriceabc.py index 7528b3e..e409a51 100644 --- a/src/akkudoktoreos/prediction/elecpriceabc.py +++ b/src/akkudoktoreos/prediction/elecpriceabc.py @@ -7,7 +7,7 @@ Notes: from abc import abstractmethod from typing import List, Optional -from pydantic import Field +from pydantic import Field, computed_field from akkudoktoreos.prediction.predictionabc import PredictionProvider, PredictionRecord from akkudoktoreos.utils.logutil import get_logger @@ -23,10 +23,22 @@ class ElecPriceDataRecord(PredictionRecord): """ - elecprice_marketprice: Optional[float] = Field( - None, description="Electricity market price (€/KWh)" + elecprice_marketprice_wh: Optional[float] = Field( + 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): """Abstract base class for electricity price providers. diff --git a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py index c9fffec..4daa04c 100644 --- a/src/akkudoktoreos/prediction/elecpriceakkudoktor.py +++ b/src/akkudoktoreos/prediction/elecpriceakkudoktor.py @@ -134,7 +134,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider): prices_of_hour = self.elecprice_8days[hour] if np.isnan(prices_of_hour).all(): # 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: weights = self.elecprice_8days_weights_day_of_week[day_of_week] prices_of_hour_masked: NDArray[Shape["24"]] = np.ma.MaskedArray( @@ -204,24 +204,28 @@ class ElecPriceAkkudoktor(ElecPriceProvider): elecprice_cache_file.seek(0) self.elecprice_8days = np.load(elecprice_cache_file) - # Get elecprice_charges - charges = self.config.elecprice_charges if self.config.elecprice_charges else 0.0 + # Get elecprice_charges_kwh_kwh + charges_kwh = ( + self.config.elecprice_charges_kwh if self.config.elecprice_charges_kwh else 0.0 + ) for i in range(values_len): original_datetime = akkudoktor_data.values[i].start dt = to_datetime(original_datetime, in_timezone=self.config.timezone) 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: # 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 - self.elecprice_8days[dt.hour, 7] = price + self.elecprice_8days[dt.hour, 7] = price_wh record = ElecPriceDataRecord( date_time=dt, - elecprice_marketprice=price, + elecprice_marketprice_wh=price_wh, ) self.append(record) @@ -242,7 +246,7 @@ class ElecPriceAkkudoktor(ElecPriceProvider): record = ElecPriceDataRecord( date_time=dt, - elecprice_marketprice=value, + elecprice_marketprice_wh=value, ) self.insert(0, record) # Assure price ends at end_time @@ -253,6 +257,6 @@ class ElecPriceAkkudoktor(ElecPriceProvider): record = ElecPriceDataRecord( date_time=dt, - elecprice_marketprice=value, + elecprice_marketprice_wh=value, ) self.append(record) diff --git a/src/akkudoktoreos/server/fastapi_server.py b/src/akkudoktoreos/server/fastapi_server.py index 32f1e39..997ddb8 100755 --- a/src/akkudoktoreos/server/fastapi_server.py +++ b/src/akkudoktoreos/server/fastapi_server.py @@ -11,7 +11,7 @@ import pandas as pd import uvicorn from fastapi import FastAPI, Query, Request 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.core.ems import get_ems @@ -37,6 +37,98 @@ measurement_eos = get_measurement() prediction_eos = get_prediction() ems_eos = get_ems() +ERROR_PAGE_TEMPLATE = """ + + + + + + Energy Optimization System (EOS) Error + + + +
+

STATUS_CODE

+

ERROR_TITLE

+

ERROR_MESSAGE

+
ERROR_DETAILS
+ Back to Home +
+ + +""" + + +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: """Start the fasthtml server as a subprocess.""" @@ -301,10 +393,11 @@ def fastapi_prediction_list_get( @app.get("/strompreis") def fastapi_strompreis() -> list[float]: - """Deprecated: Electricity Market Price Prediction. + """Deprecated: Electricity Market Price Prediction per Wh (€/Wh). 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( elecprice_provider="ElecPriceAkkudoktor", @@ -318,7 +411,7 @@ def fastapi_strompreis() -> list[float]: # Get the current date and the end date based on prediction hours # Fetch prices for the specified date range return prediction_eos.key_to_array( - key="elecprice_marketprice", + key="elecprice_marketprice_wh", start_datetime=prediction_eos.start_datetime, end_datetime=prediction_eos.end_datetime, ).tolist() @@ -513,7 +606,7 @@ async def proxy_put(request: Request, path: str) -> Response: 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: # Proxy to fasthtml server 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() - async with httpx.AsyncClient() as client: - if request.method == "GET": - response = await client.get(url, headers=headers) - elif request.method == "POST": - response = await client.post(url, headers=headers, content=data) - elif request.method == "PUT": - response = await client.put(url, headers=headers, content=data) - elif request.method == "DELETE": - response = await client.delete(url, headers=headers, content=data) + try: + async with httpx.AsyncClient() as client: + if request.method == "GET": + response = await client.get(url, headers=headers) + elif request.method == "POST": + response = await client.post(url, headers=headers, content=data) + elif request.method == "PUT": + response = await client.put(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"""
+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.
+
+""", + error_details=f"{e}", + ) + return HTMLResponse(content=error_page, status_code=404) return Response( content=response.content, diff --git a/tests/test_elecpriceakkudoktor.py b/tests/test_elecpriceakkudoktor.py index 6aa4208..a7b4357 100644 --- a/tests/test_elecpriceakkudoktor.py +++ b/tests/test_elecpriceakkudoktor.py @@ -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 np_price_array = elecprice_provider.key_to_array( - key="elecprice_marketprice", + key="elecprice_marketprice_wh", start_datetime=elecprice_provider.start_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.""" elecprice_provider.update_data(force_update=True) array = elecprice_provider.key_to_array( - key="elecprice_marketprice", + key="elecprice_marketprice_wh", start_datetime=elecprice_provider.start_datetime, end_datetime=elecprice_provider.end_datetime, ) diff --git a/tests/test_elecpriceimport.py b/tests/test_elecpriceimport.py index ee33126..dacb7b4 100644 --- a/tests/test_elecpriceimport.py +++ b/tests/test_elecpriceimport.py @@ -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.total_hours is not None 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( 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_value = values[expected_value_index] 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}: Result: {result_datetime}:{result_value}") diff --git a/tests/testdata/import_input_1.json b/tests/testdata/import_input_1.json index 7672ffd..0fa980e 100644 --- a/tests/testdata/import_input_1.json +++ b/tests/testdata/import_input_1.json @@ -6,14 +6,14 @@ 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 ], - "elecprice_marketprice": [ - 0.3384, 0.3318, 0.3284, 0.3283, 0.3289, 0.3334, 0.3290, - 0.3302, 0.3042, 0.2430, 0.2280, 0.2212, 0.2093, 0.1879, - 0.1838, 0.2004, 0.2198, 0.2270, 0.2997, 0.3195, 0.3081, - 0.2969, 0.2921, 0.2780, 0.3384, 0.3318, 0.3284, 0.3283, - 0.3289, 0.3334, 0.3290, 0.3302, 0.3042, 0.2430, 0.2280, - 0.2212, 0.2093, 0.1879, 0.1838, 0.2004, 0.2198, 0.2270, - 0.2997, 0.3195, 0.3081, 0.2969, 0.2921, 0.2780 + "elecprice_marketprice_wh": [ + 0.0003384, 0.0003318, 0.0003284, 0.0003283, 0.0003289, 0.0003334, 0.0003290, + 0.0003302, 0.0003042, 0.0002430, 0.0002280, 0.0002212, 0.0002093, 0.0001879, + 0.0001838, 0.0002004, 0.0002198, 0.0002270, 0.0002997, 0.0003195, 0.0003081, + 0.0002969, 0.0002921, 0.0002780, 0.0003384, 0.0003318, 0.0003284, 0.0003283, + 0.0003289, 0.0003334, 0.0003290, 0.0003302, 0.0003042, 0.0002430, 0.0002280, + 0.0002212, 0.0002093, 0.0001879, 0.0001838, 0.0002004, 0.0002198, 0.0002270, + 0.0002997, 0.0003195, 0.0003081, 0.0002969, 0.0002921, 0.0002780 ], "pvforecast_ac_power": [ 0, 0, 0, 0, 0, 0, 0, 8.05, 352.91, 728.51, 930.28, 1043.25, 1106.74, 1161.69,