74 lines
3.4 KiB
Python
74 lines
3.4 KiB
Python
"""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")
|
|
BINARY_RESPONSE = {200: {"description": "Versioned little-endian Float32 packet",
|
|
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}}}
|
|
# 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="读取带版本的水力专题层静态几何",
|
|
response_class=Response, responses=BINARY_RESPONSE)
|
|
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 水力时间块",
|
|
response_class=Response, responses=BINARY_RESPONSE)
|
|
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"})
|