UPD: Station Model and filtering and aggregating in sql not in python

This commit is contained in:
sebastian 2026-06-30 19:24:48 +02:00
parent 67772affc3
commit ed0d2897ce
4 changed files with 131 additions and 65 deletions

14
main.py
View File

@ -1,9 +1,11 @@
import functools import functools
from datetime import datetime from datetime import datetime
from typing import List
from fastapi import FastAPI, Depends, Query from fastapi import FastAPI, Depends, Query
from enum import Enum from enum import Enum
from sqlmodel import Session from sqlmodel import Session
from fastapi.middleware.cors import CORSMiddleware
from db import get_session from db import get_session
from models.measurement import IndoorMeasurementCreateRequest, MeasurementListResponse, OutdoorMeasurementCreateRequest, \ from models.measurement import IndoorMeasurementCreateRequest, MeasurementListResponse, OutdoorMeasurementCreateRequest, \
@ -13,9 +15,15 @@ from models.station import StationCreateRequest, StationCreateResponse, Station,
from services import stationService, measurementService from services import stationService, measurementService
app = FastAPI() app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4200"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/stations/list", response_model=List[StationListResponse], status_code=201)
@app.get("/stations/list")
async def list_stations(session: Session = Depends(get_session)): async def list_stations(session: Session = Depends(get_session)):
result = stationService.list_stations(session) result = stationService.list_stations(session)
return [StationListResponse.model_validate(station) for station in result] return [StationListResponse.model_validate(station) for station in result]
@ -48,6 +56,6 @@ async def get_measurements(
from_timestamp: datetime | None = None, from_timestamp: datetime | None = None,
to_timestamp: datetime | None = None, to_timestamp: datetime | None = None,
resolution: MeasurementResolution = MeasurementResolution.hourly, resolution: MeasurementResolution = MeasurementResolution.hourly,
limit: int = 100, limit: int | None = None,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
return measurementService.get_measurements(session, station_ids, indoor, from_timestamp, to_timestamp, limit, resolution) return measurementService.get_measurements(session, station_ids, indoor, from_timestamp, to_timestamp, limit, resolution)

View File

@ -12,6 +12,7 @@ class Station(SQLModel, table=True):
mac: str = Field(unique=True) mac: str = Field(unique=True)
name: Optional[str] = Field(default=None) name: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=utc_now) created_at: datetime = Field(default_factory=utc_now)
indoor_only: bool = Field(default=True)
class StationCreateRequest(SQLModel): class StationCreateRequest(SQLModel):
name: str name: str
@ -24,7 +25,8 @@ class StationCreateResponse(SQLModel):
class StationListResponse(SQLModel): class StationListResponse(SQLModel):
id: int id: int
name: str name: str
created_at: datetime mac: str
indoor_only: bool
class StationUpdateRequest(SQLModel): class StationUpdateRequest(SQLModel):
id: int id: int

View File

@ -1,6 +1,4 @@
from datetime import datetime from datetime import datetime
from itertools import groupby
from statistics import mean
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
@ -52,32 +50,22 @@ def push_outdoor_measurement(session: Session, raw_measurement: OutdoorMeasureme
session.commit() session.commit()
from typing import Type, Union from typing import Type, Union
def aggregate(measurements: list[MeasurementResponse], resolution: MeasurementResolution) -> list[MeasurementResponse]:
def period_key(m: MeasurementResponse) -> str: def period_format(resolution: MeasurementResolution) -> str:
formats = { return {
MeasurementResolution.hourly: "%Y-%m-%d %H", MeasurementResolution.hourly: "%Y-%m-%d %H",
MeasurementResolution.daily: "%Y-%m-%d", MeasurementResolution.daily: "%Y-%m-%d",
MeasurementResolution.weekly: "%Y-%W", MeasurementResolution.weekly: "%Y-%W",
MeasurementResolution.monthly: "%Y-%m", MeasurementResolution.monthly: "%Y-%m",
MeasurementResolution.yearly: "%Y", MeasurementResolution.yearly: "%Y",
} }[resolution]
return m.timestamp.strftime(formats[resolution]) from sqlalchemy import func
from sqlmodel import select
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 _query_measurements( def _query_measurements(
session: Session, session: Session,
model: Type[Union[IndoorMeasurement, OutdoorMeasurement]], model: Type[IndoorMeasurement | OutdoorMeasurement],
indoor: bool, indoor: bool,
station_ids: list[int] | None, station_ids: list[int] | None,
from_timestamp: datetime | None = None, from_timestamp: datetime | None = None,
@ -85,27 +73,83 @@ def _query_measurements(
limit: int | None = None, limit: int | None = None,
resolution: MeasurementResolution = MeasurementResolution.hourly, resolution: MeasurementResolution = MeasurementResolution.hourly,
) -> list[StationMeasurementResponse]: ) -> list[StationMeasurementResponse]:
#
# RAW
#
if resolution == MeasurementResolution.raw:
statement = select(model) statement = select(model)
if station_ids: if station_ids:
statement = statement.where(model.station_id.in_(station_ids)) statement = statement.where(model.station_id.in_(station_ids))
if from_timestamp: if from_timestamp:
statement = statement.where(model.timestamp >= from_timestamp) statement = statement.where(model.timestamp >= from_timestamp)
if to_timestamp: if to_timestamp:
statement = statement.where(model.timestamp <= to_timestamp) statement = statement.where(model.timestamp <= to_timestamp)
statement = statement.order_by(model.timestamp.desc()) statement = statement.order_by(model.timestamp.desc())
results = session.exec(statement).all()
if limit:
statement = statement.limit(limit)
rows = session.exec(statement).all()
grouped: dict[int, list[MeasurementResponse]] = {} 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))
if resolution != MeasurementResolution.raw: for row in rows:
grouped = { grouped.setdefault(row.station_id, []).append(
station_id: aggregate(measurements, resolution) MeasurementResponse.model_validate(row)
for station_id, measurements in grouped.items() )
}
#
# 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: if limit:
grouped = { grouped = {
@ -113,25 +157,37 @@ def _query_measurements(
for station_id, measurements in grouped.items() 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 [ return [
StationMeasurementResponse( StationMeasurementResponse(
station=StationListResponse.model_validate(session.get(Station, station_id)), station=StationListResponse.model_validate(stations[station_id]),
measurements=measurements, measurements=measurements,
indoor=indoor indoor=indoor,
) )
for station_id, measurements in grouped.items() 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]: 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) 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]: 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) 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: if indoor is None:
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)
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( return MeasurementListResponse(
stations=[ stations=[
*[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], *[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: else:
if indoor: 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( return MeasurementListResponse(
stations=[ stations=[
*[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results], *[StationMeasurementResponse(station=indoor_result.station, measurements=indoor_result.measurements, indoor=True) for indoor_result in indoor_results],
] ]
) )
else: 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( return MeasurementListResponse(
stations=[ stations=[
*[StationMeasurementResponse(station=outdoor_result.station, measurements=outdoor_result.measurements, indoor=False) for outdoor_result in outdoor_results], *[StationMeasurementResponse(station=outdoor_result.station, measurements=outdoor_result.measurements, indoor=False) for outdoor_result in outdoor_results],

View File

@ -99,8 +99,8 @@ def generate_outdoor_rows(station_id: int, start: datetime, end: datetime) -> li
# ── Main ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
def simulate(): def simulate():
start = datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc) start = datetime(2025, 1, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2024, 12, 31, 23, 59, tzinfo=timezone.utc) end = datetime(2026, 6, 28, 20, 0, tzinfo=timezone.utc)
engine = create_engine(f"sqlite:///{DB_PATH}") engine = create_engine(f"sqlite:///{DB_PATH}")