mirror of
				https://github.com/Akkudoktor-EOS/EOS.git
				synced 2025-10-30 22:36:21 +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:
		| @@ -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,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"""<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, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user