mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-10-31 06:46:20 +00:00 
			
		
		
		
	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:
		| @@ -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}" | ||||
|   | ||||
| @@ -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": { | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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)." | ||||
|     ) | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 = """ | ||||
| <!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: | ||||
|     """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,6 +614,7 @@ async def proxy(request: Request, path: str) -> Union[Response | RedirectRespons | ||||
|  | ||||
|         data = await request.body() | ||||
|  | ||||
|         try: | ||||
|             async with httpx.AsyncClient() as client: | ||||
|                 if request.method == "GET": | ||||
|                     response = await client.get(url, headers=headers) | ||||
| @@ -530,6 +624,21 @@ async def proxy(request: Request, path: str) -> Union[Response | RedirectRespons | ||||
|                     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"""<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( | ||||
|             content=response.content, | ||||
|   | ||||
| @@ -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, | ||||
|     ) | ||||
|   | ||||
| @@ -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}") | ||||
|   | ||||
							
								
								
									
										16
									
								
								tests/testdata/import_input_1.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								tests/testdata/import_input_1.json
									
									
									
									
										vendored
									
									
								
							| @@ -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, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user