feat(api): add compact hydraulic playback endpoints
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
"""Project-authorized compact hydraulic playback transport."""
|
||||
|
||||
from collections import OrderedDict
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from app.auth.project_dependencies import ProjectContext, get_project_context, get_project_pg_connection
|
||||
from app.services.hydraulic_playback import MAX_FRAME_COUNT, PlaybackGeometry, read_frames, read_geometry
|
||||
from app.domain.time import parse_utc_time
|
||||
from .dependencies import get_timescale_connection
|
||||
|
||||
router = APIRouter(prefix="/timeseries/realtime/playback")
|
||||
# Bounded per-process cache, private to the authorized project and database route.
|
||||
_cache: OrderedDict[tuple, tuple[float, PlaybackGeometry]] = OrderedDict()
|
||||
_load_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def geometry_for_project(ctx: ProjectContext, conn: AsyncConnection) -> PlaybackGeometry:
|
||||
key = (str(ctx.project_id), conn.info.host, conn.info.port, conn.info.dbname)
|
||||
cached = _cache.get(key)
|
||||
if cached and time.monotonic() - cached[0] < 60:
|
||||
_cache.move_to_end(key)
|
||||
return cached[1]
|
||||
async with _load_lock:
|
||||
cached = _cache.get(key)
|
||||
if cached and time.monotonic() - cached[0] < 60:
|
||||
return cached[1]
|
||||
geometry = await read_geometry(conn)
|
||||
_cache[key] = (time.monotonic(), geometry)
|
||||
_cache.move_to_end(key)
|
||||
while len(_cache) > 4:
|
||||
_cache.popitem(last=False)
|
||||
return geometry
|
||||
|
||||
|
||||
@router.get("/geometry", summary="读取带版本的水力专题层静态几何")
|
||||
async def playback_geometry(ctx: ProjectContext = Depends(get_project_context),
|
||||
conn: AsyncConnection = Depends(get_project_pg_connection)):
|
||||
try:
|
||||
geometry = await geometry_for_project(ctx, conn)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
return Response(geometry.content, media_type="application/octet-stream",
|
||||
headers={"Cache-Control": "private, no-store", "ETag": f'"{geometry.version}"'})
|
||||
|
||||
|
||||
@router.get("/frames", summary="读取索引一致的 Float32 水力时间块")
|
||||
async def playback_frames(
|
||||
start_time: str,
|
||||
model_version: str = Query(min_length=24, max_length=24),
|
||||
frame_count: int = Query(default=4, ge=1, le=MAX_FRAME_COUNT),
|
||||
step_seconds: int = Query(default=900, ge=60, le=3600),
|
||||
ctx: ProjectContext = Depends(get_project_context),
|
||||
pg_conn: AsyncConnection = Depends(get_project_pg_connection),
|
||||
ts_conn: AsyncConnection = Depends(get_timescale_connection),
|
||||
):
|
||||
try:
|
||||
start = parse_utc_time(start_time, field_name="start_time")
|
||||
geometry = await geometry_for_project(ctx, pg_conn)
|
||||
if geometry.version != model_version:
|
||||
raise HTTPException(409, "管网模型已更新,请重新加载专题层")
|
||||
content = await read_frames(ts_conn, geometry, start, frame_count, step_seconds)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
return Response(content, media_type="application/octet-stream",
|
||||
headers={"Cache-Control": "private, no-store"})
|
||||
@@ -47,6 +47,7 @@ from app.api.v1.endpoints.timeseries import (
|
||||
analysis as ts_analysis,
|
||||
composite as ts_composite,
|
||||
realtime as ts_realtime,
|
||||
playback as ts_playback,
|
||||
scada as ts_scada,
|
||||
)
|
||||
from app.auth.permissions import (
|
||||
@@ -191,6 +192,7 @@ api_router.include_router(
|
||||
# TimescaleDB data
|
||||
for endpoint_router, tag in (
|
||||
(ts_realtime.router, "TimescaleDB - Realtime"),
|
||||
(ts_playback.router, "TimescaleDB - Hydraulic Playback"),
|
||||
(ts_analysis.router, "TimescaleDB - Analysis"),
|
||||
):
|
||||
api_router.include_router(
|
||||
|
||||
Reference in New Issue
Block a user