diff --git a/main.py b/main.py index f94b041..689aac7 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,11 @@ import functools from datetime import datetime +from typing import List from fastapi import FastAPI, Depends, Query from enum import Enum from sqlmodel import Session +from fastapi.middleware.cors import CORSMiddleware from db import get_session from models.measurement import IndoorMeasurementCreateRequest, MeasurementListResponse, OutdoorMeasurementCreateRequest, \ @@ -13,9 +15,15 @@ from models.station import StationCreateRequest, StationCreateResponse, Station, from services import stationService, measurementService app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:4200"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) - -@app.get("/stations/list") +@app.get("/stations/list", response_model=List[StationListResponse], status_code=201) async def list_stations(session: Session = Depends(get_session)): result = stationService.list_stations(session) return [StationListResponse.model_validate(station) for station in result] @@ -48,6 +56,6 @@ async def get_measurements( from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, resolution: MeasurementResolution = MeasurementResolution.hourly, - limit: int = 100, + limit: int | None = None, session: Session = Depends(get_session)): return measurementService.get_measurements(session, station_ids, indoor, from_timestamp, to_timestamp, limit, resolution) diff --git a/models/station.py b/models/station.py index 59089e4..ceb5844 100644 --- a/models/station.py +++ b/models/station.py @@ -12,6 +12,7 @@ class Station(SQLModel, table=True): mac: str = Field(unique=True) name: Optional[str] = Field(default=None) created_at: datetime = Field(default_factory=utc_now) + indoor_only: bool = Field(default=True) class StationCreateRequest(SQLModel): name: str @@ -24,7 +25,8 @@ class StationCreateResponse(SQLModel): class StationListResponse(SQLModel): id: int name: str - created_at: datetime + mac: str + indoor_only: bool class StationUpdateRequest(SQLModel): id: int diff --git a/services/measurementService.py b/services/measurementService.py index b25be8b..27f32d7 100644 --- a/services/measurementService.py +++ b/services/measurementService.py @@ -1,6 +1,4 @@ from datetime import datetime -from itertools import groupby -from statistics import mean from sqlalchemy import func from sqlmodel import Session, select @@ -52,32 +50,22 @@ def push_outdoor_measurement(session: Session, raw_measurement: OutdoorMeasureme session.commit() from typing import Type, Union -def aggregate(measurements: list[MeasurementResponse], resolution: MeasurementResolution) -> list[MeasurementResponse]: - def period_key(m: MeasurementResponse) -> str: - formats = { - MeasurementResolution.hourly: "%Y-%m-%d %H", - MeasurementResolution.daily: "%Y-%m-%d", - MeasurementResolution.weekly: "%Y-%W", - MeasurementResolution.monthly: "%Y-%m", - MeasurementResolution.yearly: "%Y", - } - return m.timestamp.strftime(formats[resolution]) - result = [] - sorted_measurements = sorted(measurements, key=period_key) - for _, group in groupby(sorted_measurements, key=period_key): - group_list = list(group) - result.append(MeasurementResponse( - timestamp=group_list[0].timestamp, - temperature=round(mean(m.temperature for m in group_list if m.temperature is not None), 1), - humidity=round(mean(m.humidity for m in group_list if m.humidity is not None), 1) if any(m.humidity for m in group_list) else None, - pressure=round(mean(m.pressure for m in group_list if m.pressure is not None), 1) if any(m.pressure for m in group_list) else None, - )) - return result +def period_format(resolution: MeasurementResolution) -> str: + return { + MeasurementResolution.hourly: "%Y-%m-%d %H", + MeasurementResolution.daily: "%Y-%m-%d", + MeasurementResolution.weekly: "%Y-%W", + MeasurementResolution.monthly: "%Y-%m", + MeasurementResolution.yearly: "%Y", + }[resolution] +from sqlalchemy import func +from sqlmodel import select + def _query_measurements( session: Session, - model: Type[Union[IndoorMeasurement, OutdoorMeasurement]], + model: Type[IndoorMeasurement | OutdoorMeasurement], indoor: bool, station_ids: list[int] | None, from_timestamp: datetime | None = None, @@ -85,53 +73,121 @@ def _query_measurements( limit: int | None = None, resolution: MeasurementResolution = MeasurementResolution.hourly, ) -> list[StationMeasurementResponse]: - statement = select(model) - if station_ids: - statement = statement.where(model.station_id.in_(station_ids)) - if from_timestamp: - statement = statement.where(model.timestamp >= from_timestamp) - if to_timestamp: - statement = statement.where(model.timestamp <= to_timestamp) - statement = statement.order_by(model.timestamp.desc()) - results = session.exec(statement).all() - grouped: dict[int, list[MeasurementResponse]] = {} - for m in results: - if m.station_id not in grouped: - grouped[m.station_id] = [] - grouped[m.station_id].append(MeasurementResponse.model_validate(m)) + # + # RAW + # + if resolution == MeasurementResolution.raw: + statement = select(model) - if resolution != MeasurementResolution.raw: - grouped = { - station_id: aggregate(measurements, resolution) - for station_id, measurements in grouped.items() - } + if station_ids: + statement = statement.where(model.station_id.in_(station_ids)) - if limit: - grouped = { - station_id: measurements[:limit] - for station_id, measurements in grouped.items() - } + if from_timestamp: + statement = statement.where(model.timestamp >= from_timestamp) + + if to_timestamp: + statement = statement.where(model.timestamp <= to_timestamp) + + statement = statement.order_by(model.timestamp.desc()) + + if limit: + statement = statement.limit(limit) + + rows = session.exec(statement).all() + + grouped: dict[int, list[MeasurementResponse]] = {} + + for row in rows: + grouped.setdefault(row.station_id, []).append( + MeasurementResponse.model_validate(row) + ) + + # + # AGGREGATED + # + else: + period = func.strftime(period_format(resolution), model.timestamp) + + columns = [ + model.station_id.label("station_id"), + func.max(model.timestamp).label("timestamp"), + func.round(func.avg(model.temperature), 1).label("temperature"), + func.round(func.avg(model.humidity), 1).label("humidity"), + ] + + if model is OutdoorMeasurement: + columns.append( + func.round(func.avg(model.pressure), 1).label("pressure") + ) + + statement = ( + select(*columns) + .group_by(model.station_id, period) + .order_by(func.max(model.timestamp).desc()) + ) + + if station_ids: + statement = statement.where(model.station_id.in_(station_ids)) + + if from_timestamp: + statement = statement.where(model.timestamp >= from_timestamp) + + if to_timestamp: + statement = statement.where(model.timestamp <= to_timestamp) + + print(str(statement.compile(compile_kwargs={"literal_binds": True}))) + rows = session.exec(statement).all() + + grouped: dict[int, list[MeasurementResponse]] = {} + + for row in rows: + grouped.setdefault(row.station_id, []).append( + MeasurementResponse( + timestamp=row.timestamp, + temperature=row.temperature, + humidity=row.humidity, + pressure=getattr(row, "pressure", None), + ) + ) + + if limit: + grouped = { + station_id: measurements[:limit] + for station_id, measurements in grouped.items() + } + + # + # Stations laden + # + station_ids = list(grouped.keys()) + + stations = { + s.id: s + for s in session.exec( + select(Station).where(Station.id.in_(station_ids)) + ) + } return [ StationMeasurementResponse( - station=StationListResponse.model_validate(session.get(Station, station_id)), + station=StationListResponse.model_validate(stations[station_id]), measurements=measurements, - indoor=indoor + indoor=indoor, ) for station_id, measurements in grouped.items() ] -def get_indoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None) -> list[StationMeasurementResponse]: - return _query_measurements(session, IndoorMeasurement, True, station_ids, from_timestamp, to_timestamp, limit) +def get_indoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None, resolution: MeasurementResolution = MeasurementResolution.hourly) -> list[StationMeasurementResponse]: + return _query_measurements(session, IndoorMeasurement, True, station_ids, from_timestamp, to_timestamp, limit, resolution) -def get_outdoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None) -> list[StationMeasurementResponse]: - return _query_measurements(session, OutdoorMeasurement, False, station_ids, from_timestamp, to_timestamp, limit) +def get_outdoor_measurements(session: Session, station_ids: list[int] | None, from_timestamp: datetime | None = None, to_timestamp: datetime | None = None, limit: int | None = None, resolution: MeasurementResolution = MeasurementResolution.hourly) -> list[StationMeasurementResponse]: + return _query_measurements(session, OutdoorMeasurement, False, station_ids, from_timestamp, to_timestamp, limit, resolution) -def get_measurements(session: Session, station_ids: list[int] | None, indoor: bool, from_timestamp: datetime | None, to_timestamp: datetime | None,limit: int, resolution: MeasurementResolution ): +def get_measurements(session: Session, station_ids: list[int] | None, indoor: bool, from_timestamp: datetime | None, to_timestamp: datetime | None,limit: int | None, resolution: MeasurementResolution ): if indoor is None: - indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) - outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit, resolution) + outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit, resolution) return MeasurementListResponse( stations=[ *[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], @@ -140,14 +196,14 @@ def get_measurements(session: Session, station_ids: list[int] | None, indoor: bo ) else: if indoor: - indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + indoor_results = get_indoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit, resolution) return MeasurementListResponse( stations=[ *[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], ] ) else: - outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit) + outdoor_results = get_outdoor_measurements(session, station_ids, from_timestamp, to_timestamp, limit, resolution) return MeasurementListResponse( stations=[ *[StationMeasurementResponse(station=outdoor_result.station, measurements=outdoor_result.measurements, indoor=False) for outdoor_result in outdoor_results], diff --git a/simulate_measurements.py b/simulate_measurements.py index d40bd95..3e84a4d 100644 --- a/simulate_measurements.py +++ b/simulate_measurements.py @@ -99,8 +99,8 @@ def generate_outdoor_rows(station_id: int, start: datetime, end: datetime) -> li # ── Main ────────────────────────────────────────────────────────────────────── def simulate(): - start = datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc) - end = datetime(2024, 12, 31, 23, 59, tzinfo=timezone.utc) + start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc) + end = datetime(2026, 6, 28, 20, 0, tzinfo=timezone.utc) engine = create_engine(f"sqlite:///{DB_PATH}")