mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-09-18 09:41:14 +00:00
Improve Configuration and Prediction Usability (#220)
* Update utilities in utils submodule. * Add base configuration modules. * Add server base configuration modules. * Add devices base configuration modules. * Add optimization base configuration modules. * Add utils base configuration modules. * Add prediction abstract and base classes plus tests. * Add PV forecast to prediction submodule. The PV forecast modules are adapted from the class_pvforecast module and replace it. * Add weather forecast to prediction submodule. The modules provide classes and methods to retrieve, manage, and process weather forecast data from various sources. Includes are structured representations of weather data and utilities for fetching forecasts for specific locations and time ranges. BrightSky and ClearOutside are currently supported. * Add electricity price forecast to prediction submodule. * Adapt fastapi server to base config and add fasthtml server. * Add ems to core submodule. * Adapt genetic to config. * Adapt visualize to config. * Adapt common test fixtures to config. * Add load forecast to prediction submodule. * Add core abstract and base classes. * Adapt single test optimization to config. * Adapt devices to config. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -1,38 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Dict, List, Optional
|
||||
|
||||
import matplotlib
|
||||
import uvicorn
|
||||
from fastapi.exceptions import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Sets the Matplotlib backend to 'Agg' for rendering plots in environments without a display
|
||||
matplotlib.use("Agg")
|
||||
from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from pendulum import DateTime
|
||||
|
||||
from akkudoktoreos.config import (
|
||||
SetupIncomplete,
|
||||
get_start_enddate,
|
||||
get_working_dir,
|
||||
load_config,
|
||||
)
|
||||
from akkudoktoreos.config.config import ConfigEOS, SettingsEOS, get_config
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.optimization.genetic import (
|
||||
OptimizationParameters,
|
||||
OptimizeResponse,
|
||||
optimization_problem,
|
||||
)
|
||||
|
||||
# Still to be adapted
|
||||
from akkudoktoreos.prediction.load_container import Gesamtlast
|
||||
from akkudoktoreos.prediction.load_corrector import LoadPredictionAdjuster
|
||||
from akkudoktoreos.prediction.load_forecast import LoadForecast
|
||||
from akkudoktoreos.prediction.price_forecast import HourlyElectricityPriceForecast
|
||||
from akkudoktoreos.prediction.pv_forecast import ForecastResponse, PVForecast
|
||||
from akkudoktoreos.prediction.prediction import get_prediction
|
||||
from akkudoktoreos.utils.logutil import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
config_eos = get_config()
|
||||
prediction_eos = get_prediction()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Lifespan manager for the app."""
|
||||
# On startup
|
||||
if config_eos.server_fasthtml_host and config_eos.server_fasthtml_port:
|
||||
try:
|
||||
fasthtml_process = start_fasthtml_server()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start FastHTML server. Error: {e}")
|
||||
sys.exit(1)
|
||||
# Handover to application
|
||||
yield
|
||||
# On shutdown
|
||||
# nothing to do
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Akkudoktor-EOS",
|
||||
@@ -43,12 +59,12 @@ app = FastAPI(
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
},
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
working_dir = get_working_dir()
|
||||
# copy config to working directory. Make this a CLI option later
|
||||
config = load_config(working_dir, True)
|
||||
opt_class = optimization_problem(config)
|
||||
# That's the problem
|
||||
opt_class = optimization_problem()
|
||||
|
||||
server_dir = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
@@ -56,22 +72,44 @@ class PdfResponse(FileResponse):
|
||||
media_type = "application/pdf"
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
def fastapi_config_get() -> ConfigEOS:
|
||||
"""Get the current configuration."""
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.put("/config")
|
||||
def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
|
||||
"""Merge settings into current configuration."""
|
||||
config_eos.merge_settings(settings)
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.get("/prediction/keys")
|
||||
def fastapi_prediction_keys() -> list[str]:
|
||||
"""Get a list of available prediction keys."""
|
||||
return sorted(list(prediction_eos.keys()))
|
||||
|
||||
|
||||
@app.get("/prediction")
|
||||
def fastapi_prediction(key: str) -> list[Union[float | str]]:
|
||||
"""Get the current configuration."""
|
||||
values = prediction_eos[key].to_list()
|
||||
return values
|
||||
|
||||
|
||||
@app.get("/strompreis")
|
||||
def fastapi_strompreis() -> list[float]:
|
||||
# Get the current date and the end date based on prediction hours
|
||||
date_now, date = get_start_enddate(config.eos.prediction_hours, startdate=datetime.now().date())
|
||||
price_forecast = HourlyElectricityPriceForecast(
|
||||
source=f"https://api.akkudoktor.net/prices?start={date_now}&end={date}",
|
||||
config=config,
|
||||
use_cache=False,
|
||||
)
|
||||
specific_date_prices = price_forecast.get_price_for_daterange(
|
||||
date_now, date
|
||||
) # Fetch prices for the specified date range
|
||||
marketprice_series = prediction_eos["elecprice_marketprice"]
|
||||
# Fetch prices for the specified date range
|
||||
specific_date_prices = marketprice_series.loc[
|
||||
prediction_eos.start_datetime : prediction_eos.end_datetime
|
||||
]
|
||||
return specific_date_prices.tolist()
|
||||
|
||||
|
||||
class GesamtlastRequest(BaseModel):
|
||||
class GesamtlastRequest(PydanticBaseModel):
|
||||
year_energy: float
|
||||
measured_data: List[Dict[str, Any]]
|
||||
hours: int
|
||||
@@ -134,26 +172,19 @@ def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
||||
|
||||
@app.get("/gesamtlast_simple")
|
||||
def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
|
||||
date_now, date = get_start_enddate(
|
||||
config.eos.prediction_hours, startdate=datetime.now().date()
|
||||
) # Get the current date and prediction end date
|
||||
|
||||
###############
|
||||
# Load Forecast
|
||||
###############
|
||||
lf = LoadForecast(
|
||||
filepath=server_dir / ".." / "data" / "load_profiles.npz", year_energy=year_energy
|
||||
) # Instantiate LoadForecast with specified parameters
|
||||
leistung_haushalt = lf.get_stats_for_date_range(date_now, date)[
|
||||
0
|
||||
] # Get expected household load for the date range
|
||||
leistung_haushalt = lf.get_stats_for_date_range(
|
||||
prediction_eos.start_datetime, prediction_eos.end_datetime
|
||||
)[0] # Get expected household load for the date range
|
||||
|
||||
gesamtlast = Gesamtlast(
|
||||
prediction_hours=config.eos.prediction_hours
|
||||
) # Create Gesamtlast instance
|
||||
gesamtlast.hinzufuegen(
|
||||
"Haushalt", leistung_haushalt
|
||||
) # Add household load to total load calculation
|
||||
prediction_hours = config_eos.prediction_hours if config_eos.prediction_hours else 48
|
||||
gesamtlast = Gesamtlast(prediction_hours=prediction_hours) # Create Gesamtlast instance
|
||||
gesamtlast.hinzufuegen("Haushalt", leistung_haushalt) # Add household to total load calculation
|
||||
|
||||
# ###############
|
||||
# # WP (Heat Pump)
|
||||
@@ -165,27 +196,31 @@ def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
|
||||
return last.tolist() # Return total load as JSON
|
||||
|
||||
|
||||
@app.get("/pvforecast")
|
||||
def fastapi_pvprognose(url: str, ac_power_measurement: Optional[float] = None) -> ForecastResponse:
|
||||
date_now, date = get_start_enddate(config.eos.prediction_hours, startdate=datetime.now().date())
|
||||
class ForecastResponse(PydanticBaseModel):
|
||||
temperature: list[float]
|
||||
pvpower: list[float]
|
||||
|
||||
|
||||
@app.get("/pvforecast")
|
||||
def fastapi_pvprognose(ac_power_measurement: Optional[float] = None) -> ForecastResponse:
|
||||
###############
|
||||
# PV Forecast
|
||||
###############
|
||||
PVforecast = PVForecast(
|
||||
prediction_hours=config.eos.prediction_hours, url=url
|
||||
) # Instantiate PVForecast with given parameters
|
||||
if ac_power_measurement is not None:
|
||||
PVforecast.update_ac_power_measurement(
|
||||
date_time=datetime.now(),
|
||||
ac_power_measurement=ac_power_measurement,
|
||||
) # Update measurement
|
||||
pvforecast_ac_power = prediction_eos["pvforecast_ac_power"]
|
||||
# Fetch prices for the specified date range
|
||||
pvforecast_ac_power = pvforecast_ac_power.loc[
|
||||
prediction_eos.start_datetime : prediction_eos.end_datetime
|
||||
]
|
||||
pvforecastakkudoktor_temp_air = prediction_eos["pvforecastakkudoktor_temp_air"]
|
||||
# Fetch prices for the specified date range
|
||||
pvforecastakkudoktor_temp_air = pvforecastakkudoktor_temp_air.loc[
|
||||
prediction_eos.start_datetime : prediction_eos.end_datetime
|
||||
]
|
||||
|
||||
# Get PV forecast and temperature forecast for the specified date range
|
||||
pv_forecast = PVforecast.get_pv_forecast_for_date_range(date_now, date)
|
||||
temperature_forecast = PVforecast.get_temperature_for_date_range(date_now, date)
|
||||
|
||||
return ForecastResponse(temperature=temperature_forecast.tolist(), pvpower=pv_forecast.tolist())
|
||||
# Return both forecasts as a JSON response
|
||||
return ForecastResponse(
|
||||
temperature=pvforecastakkudoktor_temp_air.tolist(), pvpower=pvforecast_ac_power.tolist()
|
||||
)
|
||||
|
||||
|
||||
@app.post("/optimize")
|
||||
@@ -196,7 +231,11 @@ def fastapi_optimize(
|
||||
] = None,
|
||||
) -> OptimizeResponse:
|
||||
if start_hour is None:
|
||||
start_hour = datetime.now().hour
|
||||
start_hour = DateTime.now().hour
|
||||
|
||||
# TODO: Remove when config and prediction update is done by EMS.
|
||||
config_eos.update()
|
||||
prediction_eos.update_data()
|
||||
|
||||
# Perform optimization simulation
|
||||
result = opt_class.optimierung_ems(parameters=parameters, start_hour=start_hour)
|
||||
@@ -207,9 +246,9 @@ def fastapi_optimize(
|
||||
@app.get("/visualization_results.pdf", response_class=PdfResponse)
|
||||
def get_pdf() -> PdfResponse:
|
||||
# Endpoint to serve the generated PDF with visualization results
|
||||
output_path = config.working_dir / config.directories.output
|
||||
output_path = config_eos.data_output_path
|
||||
if not output_path.is_dir():
|
||||
raise SetupIncomplete(f"Output path does not exist: {output_path}.")
|
||||
raise ValueError(f"Output path does not exist: {output_path}.")
|
||||
file_path = output_path / "visualization_results.pdf"
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="No visualization result available.")
|
||||
@@ -221,29 +260,85 @@ def site_map() -> RedirectResponse:
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
# Redirect the root URL to the site map
|
||||
return RedirectResponse(url="/docs")
|
||||
# Keep the proxy last to handle all requests that are not taken by the Rest API.
|
||||
# Also keep the single endpoints for delete, get, post, put to assure openapi.json is always build
|
||||
# the same way for testing.
|
||||
|
||||
|
||||
@app.delete("/{path:path}")
|
||||
async def proxy_delete(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def proxy_get(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
|
||||
@app.post("/{path:path}")
|
||||
async def proxy_post(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
|
||||
@app.put("/{path:path}")
|
||||
async def proxy_put(request: Request, path: str) -> Response:
|
||||
return await proxy(request, path)
|
||||
|
||||
|
||||
async def proxy(request: Request, path: str) -> Union[Response | RedirectResponse]:
|
||||
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}"
|
||||
headers = dict(request.headers)
|
||||
|
||||
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)
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
)
|
||||
else:
|
||||
# Redirect the root URL to the site map
|
||||
return RedirectResponse(url="/docs")
|
||||
|
||||
|
||||
def start_fasthtml_server() -> subprocess.Popen:
|
||||
"""Start the fasthtml server as a subprocess."""
|
||||
server_process = subprocess.Popen(
|
||||
[sys.executable, str(server_dir.joinpath("fasthtml_server.py"))],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return server_process
|
||||
|
||||
|
||||
def start_fastapi_server() -> None:
|
||||
"""Start FastAPI server."""
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=str(config_eos.server_fastapi_host),
|
||||
port=config_eos.server_fastapi_port,
|
||||
log_level="debug",
|
||||
access_log=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not bind to host {config_eos.server_fastapi_host}:{config_eos.server_fastapi_port}. Error: {e}"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
config.run_setup()
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize: {e}")
|
||||
exit(1)
|
||||
|
||||
# Set host and port from environment variables or defaults
|
||||
host = os.getenv("EOS_RUN_HOST", "0.0.0.0")
|
||||
port = os.getenv("EOS_RUN_PORT", 8503)
|
||||
try:
|
||||
uvicorn.run(app, host=host, port=int(port)) # Run the FastAPI application
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Could not bind to host {host}:{port}. Error: {e}"
|
||||
) # Error handling for binding issues
|
||||
exit(1)
|
||||
else:
|
||||
# started from cli / dev server
|
||||
config.run_setup()
|
||||
start_fastapi_server()
|
||||
|
Reference in New Issue
Block a user