feat: add energy, weather and consumption endpoints with models and error handling

This commit is contained in:
Esa Kataja
2025-08-04 19:09:07 +03:00
parent f1b8097f7a
commit 8fba6cfa1e
7 changed files with 257 additions and 1 deletions
+8
View File
@@ -17,3 +17,11 @@ class FetchException(HTTPException):
status_code=status_code, status_code=status_code,
detail=f"Failed to fetch job postings from {url} with 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.",
)
+15
View File
@@ -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")
+25
View File
@@ -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"))
+10 -1
View File
@@ -1,6 +1,15 @@
from .default import router as default_router from .default import router as default_router
from .job_postings import router as job_postings_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"] __all__ = ["routes"]
+115
View File
@@ -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
+58
View File
@@ -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
+26
View File
@@ -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