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
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)

View File

@ -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

View File

@ -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 = {
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",
}
return m.timestamp.strftime(formats[resolution])
}[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(
session: Session,
model: Type[Union[IndoorMeasurement, OutdoorMeasurement]],
model: Type[IndoorMeasurement | OutdoorMeasurement],
indoor: bool,
station_ids: list[int] | None,
from_timestamp: datetime | None = None,
@ -85,27 +73,83 @@ def _query_measurements(
limit: int | None = None,
resolution: MeasurementResolution = MeasurementResolution.hourly,
) -> list[StationMeasurementResponse]:
#
# RAW
#
if resolution == MeasurementResolution.raw:
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()
if limit:
statement = statement.limit(limit)
rows = 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))
if resolution != MeasurementResolution.raw:
grouped = {
station_id: aggregate(measurements, resolution)
for station_id, measurements in grouped.items()
}
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 = {
@ -113,25 +157,37 @@ def _query_measurements(
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],

View File

@ -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}")