From 8fba6cfa1e63a83126753da8e8fa18db9be210df Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Mon, 4 Aug 2025 19:09:07 +0300 Subject: [PATCH] feat: add energy, weather and consumption endpoints with models and error handling --- src/lib/exceptions.py | 8 +++ src/models/energy.py | 15 +++++ src/models/weather.py | 25 +++++++++ src/routes/__init__.py | 11 +++- src/routes/consumption.py | 115 ++++++++++++++++++++++++++++++++++++++ src/routes/energy.py | 58 +++++++++++++++++++ src/routes/weather.py | 26 +++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 src/models/energy.py create mode 100644 src/models/weather.py create mode 100644 src/routes/consumption.py create mode 100644 src/routes/energy.py create mode 100644 src/routes/weather.py diff --git a/src/lib/exceptions.py b/src/lib/exceptions.py index 1ee37a5..f9a2ad8 100644 --- a/src/lib/exceptions.py +++ b/src/lib/exceptions.py @@ -17,3 +17,11 @@ class FetchException(HTTPException): status_code=status_code, detail=f"Failed to fetch job postings from {url} with status code {status_code}", ) + + +class ParseException(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Parsing failed.", + ) diff --git a/src/models/energy.py b/src/models/energy.py new file mode 100644 index 0000000..d32cc3e --- /dev/null +++ b/src/models/energy.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, Field +from datetime import datetime + + +class Spot(BaseModel): + price: float = Field(..., description="Price in cents per kWh") + start_date: datetime = Field(..., description="Start date") + end_date: datetime = Field(..., description="End date") + + +class Consumption(BaseModel): + timestamp: datetime = Field(..., description="Timestamp") + consumption: float = Field(..., description="Consumption in kWh") + total_cost: float = Field(..., description="Total cost in euros") + vat_percentage: float = Field(..., description="VAT percentage") diff --git a/src/models/weather.py b/src/models/weather.py new file mode 100644 index 0000000..52c5373 --- /dev/null +++ b/src/models/weather.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field, field_validator +from datetime import datetime +from zoneinfo import ZoneInfo + + +class Weather(BaseModel): + name: str = Field(..., description="Name of the weather station") + localtime: datetime | str = Field(..., description="Local time") + t2m: float = Field(..., description="Temperature") + DewPoint: float | None = Field(None, description="Dew point") + Precipitation1h: float | None = Field( + None, description="Precipitation in the last hour" + ) + TotalCloudCover: int | None = Field(None, description="Total cloud cover") + WindSpeedMS: float | None = Field(None, description="Wind speed in m/s") + WindDirection: int | None = Field(None, description="Wind direction in degrees") + WindGust: float | None = Field(None, description="Wind gust in m/s") + Pressure: float | None = Field(None, description="Pressure in hPa") + Humidity: int | None = Field(None, description="Humidity in %") + Visibility: int | None = Field(None, description="Visibility in m") + SnowDepth: int | None = Field(None, description="Snow depth in cm") + + @field_validator("localtime", mode="before") + def parse_localtime(cls, v): + return datetime.fromisoformat(v).replace(tzinfo=ZoneInfo("Europe/Helsinki")) diff --git a/src/routes/__init__.py b/src/routes/__init__.py index b49acf8..6aff4be 100644 --- a/src/routes/__init__.py +++ b/src/routes/__init__.py @@ -1,6 +1,15 @@ from .default import router as default_router from .job_postings import router as job_postings_router +from .energy import router as energy_router +from .weather import router as weather_router +from .consumption import router as consumption_router -routes = [default_router, job_postings_router] +routes = [ + default_router, + job_postings_router, + energy_router, + weather_router, + consumption_router, +] __all__ = ["routes"] diff --git a/src/routes/consumption.py b/src/routes/consumption.py new file mode 100644 index 0000000..0018dc2 --- /dev/null +++ b/src/routes/consumption.py @@ -0,0 +1,115 @@ +from fastapi import APIRouter +from httpx import AsyncClient +from pydantic import BaseModel, Field +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +import json + +from lib.exceptions import FetchException, ParseException +from models.energy import Consumption + +# from models.consumption import Consumption +from lib import logger + + +router = APIRouter(prefix="/consumption", tags=["Consumption"]) + + +class _CookieData(BaseModel): + Host_next_auth_csrf_token: str = Field(..., description="CSRF token") + Secure_next_auth_session_token_0: str = Field(..., description="Session token 0") + Secure_next_auth_session_token_1: str = Field(..., description="Session token 1") + + +@router.post("/") +async def get_consumption( + cookie_data: _CookieData, + to_date: datetime = datetime.now() - timedelta(days=1), + from_date: datetime = datetime.now() - timedelta(days=14), +): + """Get consumption data for a given date range. Max 14 days. + + Args: + cookie_data: Cookie data containing CSRF token and session tokens + start_date: Start date of the date range + end_date: End date of the date range + """ + + base_url = ( + "https://www.fortum.com/fi/sahkoa/api/trpc/loggedIn.timeSeries.listTimeSeries" + ) + print(from_date.astimezone(ZoneInfo("UTC")), to_date.astimezone(ZoneInfo("UTC"))) + input_param = { + "0": { + "json": { + "meteringPointNo": ["6907998"], + "fromDate": from_date.astimezone(ZoneInfo("UTC")) + .isoformat() + .replace("+00:00", "Z"), + "toDate": to_date.astimezone(ZoneInfo("UTC")) + .isoformat() + .replace("+00:00", "Z"), + "resolution": "HOUR", + "type": "CONSUMPTION", + } + } + } + params = {"batch": 1, "input": json.dumps(input_param)} + cookies = { + "NEXT_LOCALE": "fi", + "__Host-next-auth.csrf-token": cookie_data.Host_next_auth_csrf_token, + "__Secure-next-auth.callback-url": "https://www.fortum.com/fi/sahkoa/kirjautunut/sahko", + "__Secure-next-auth.session-token.0": cookie_data.Secure_next_auth_session_token_0, + "__Secure-next-auth.session-token.1": cookie_data.Secure_next_auth_session_token_1, + } + headers = { + "accept": "application/json", + "accept-language": "en-US,en;q=0.7", + "cache-control": "no-cache", + "content-type": "application/json", + "pragma": "no-cache", + "priority": "u=1, i", + "referer": "https://www.fortum.com/fi/sahkoa/kirjautunut/sahko", + "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Brave";v="138"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "sec-gpc": "1", + "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + } + async with AsyncClient() as client: + response = await client.get( + base_url, params=params, cookies=cookies, headers=headers, timeout=30 + ) + if response.status_code != 200: + logger.error( + f"Failed to fetch data from {response.url}. Status code: {response.status_code}. Response text: {response.text}" + ) + raise FetchException(response.url, response.status_code) + data = response.json() + if not data: + logger.error( + f"Failed to parse data from {response.url}. Status code: {response.status_code}. Response text: {response.text}" + ) + raise FetchException(response.url, response.status_code) + + series_data = data[0]["result"]["data"]["json"][0]["series"] + retval: list[Consumption] = [] + for series in series_data: + if series["cost"] is not None: + try: + consumption = Consumption( + timestamp=series["atUTC"], + consumption=series["energy"][0]["value"], + total_cost=series["price"]["total"], + vat_percentage=series["price"]["vatPercentage"], + ) + retval.append(consumption) + except Exception as e: + logger.error( + f"Error parsing consumption: {str(e)}", extra={"data": series} + ) + raise ParseException() + return retval diff --git a/src/routes/energy.py b/src/routes/energy.py new file mode 100644 index 0000000..842034d --- /dev/null +++ b/src/routes/energy.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter +from httpx import AsyncClient +from datetime import date, datetime, timedelta + +from lib.exceptions import FetchException, ParseException +from models.energy import Spot +from lib import logger + +router = APIRouter(prefix="/energy", tags=["Energy"]) + + +@router.get("/spot/", responses={200: {"model": Spot}}) +async def spot(date: date = date.today(), hour: int = 0) -> Spot: + """Get spot price for a given date and hour.""" + start_date = datetime(date.year, date.month, date.day, hour) + + url = f"https://api.porssisahko.net/v1/price.json?date={date}&hour={hour}" + async with AsyncClient() as client: + response = await client.get(url) + if response.status_code != 200: + raise FetchException(response.url, response.status_code) + data = response.json() + if not data: + raise FetchException(response.url, response.status_code) + try: + spot = Spot( + price=data["price"], + start_date=start_date, + end_date=start_date + timedelta(hours=1), + ) + except Exception as e: + logger.error(f"Error parsing spot price: {str(e)}", extra={"data": data}) + raise ParseException() + return spot + + +@router.get("/spot48/", responses={200: {"model": list[Spot]}}) +async def spot48() -> list[Spot]: + """Get spot prices for the next 48 hours.""" + + url = "https://api.porssisahko.net/v1/latest-prices.json" + async with AsyncClient() as client: + response = await client.get(url) + if response.status_code != 200: + raise FetchException(response.url, response.status_code) + data = response.json() + if not data: + raise FetchException(response.url, response.status_code) + try: + spot = [ + Spot(price=d["price"], start_date=d["startDate"], end_date=d["endDate"]) + for d in data["prices"] + ] + except Exception as e: + logger.error(f"Error parsing spot prices: {str(e)}", extra={"data": data}) + raise ParseException() + + return spot diff --git a/src/routes/weather.py b/src/routes/weather.py new file mode 100644 index 0000000..e341bbe --- /dev/null +++ b/src/routes/weather.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter +from httpx import AsyncClient + +from lib.exceptions import FetchException, ParseException +from models.weather import Weather +from lib import logger + +router = APIRouter(prefix="/weather", tags=["Weather"]) + + +@router.get("/") +async def weather(): + url = "https://www.ilmatieteenlaitos.fi/api/weather/observations?fmisid=101237&observations=true&radar=false&daily=false" + async with AsyncClient() as client: + response = await client.get(url) + if response.status_code != 200: + raise FetchException(response.url, response.status_code) + data = response.json() + if not data: + raise FetchException(response.url, response.status_code) + try: + weather = [Weather(**d) for d in data["observations"] if d["t2m"] is not None] + except Exception as e: + logger.error(f"Error parsing weather: {str(e)}") + raise ParseException() + return weather