feat(api): add compact hydraulic playback endpoints
Generic Container CI/CD / test-build-publish (push) Failing after 34s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 34s

This commit is contained in:
2026-11-15 23:36:29 +08:00
parent dd31f7c159
commit f5f972bbec
5 changed files with 427 additions and 0 deletions
@@ -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"})
+2
View File
@@ -47,6 +47,7 @@ from app.api.v1.endpoints.timeseries import (
analysis as ts_analysis, analysis as ts_analysis,
composite as ts_composite, composite as ts_composite,
realtime as ts_realtime, realtime as ts_realtime,
playback as ts_playback,
scada as ts_scada, scada as ts_scada,
) )
from app.auth.permissions import ( from app.auth.permissions import (
@@ -191,6 +192,7 @@ api_router.include_router(
# TimescaleDB data # TimescaleDB data
for endpoint_router, tag in ( for endpoint_router, tag in (
(ts_realtime.router, "TimescaleDB - Realtime"), (ts_realtime.router, "TimescaleDB - Realtime"),
(ts_playback.router, "TimescaleDB - Hydraulic Playback"),
(ts_analysis.router, "TimescaleDB - Analysis"), (ts_analysis.router, "TimescaleDB - Analysis"),
): ):
api_router.include_router( api_router.include_router(
+184
View File
@@ -0,0 +1,184 @@
"""Versioned static geometry and compact, indexed hydraulic playback frames."""
from array import array
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import hashlib
import json
import math
import struct
import sys
from typing import Any
from psycopg import AsyncConnection
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
WORLD_METERS = 40075016.68557849
MAX_FRAME_COUNT = 8
def packet(header: dict[str, Any], values: array) -> bytes:
"""Four-byte aligned JSON header followed by little-endian Float32 values."""
encoded = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode()
encoded += b" " * (-len(encoded) % 4)
if sys.byteorder != "little":
values = array("f", values)
values.byteswap()
return struct.pack("<I", len(encoded)) + encoded + values.tobytes()
def mercator(longitude: float, latitude: float) -> tuple[float, float]:
latitude = max(-85.05112878, min(85.05112878, latitude))
return ((longitude + 180) / 360,
(1 - math.asinh(math.tan(math.radians(latitude))) / math.pi) / 2)
@dataclass(frozen=True)
class PlaybackGeometry:
version: str
node_ids: tuple[str, ...]
pipe_ids: tuple[str, ...]
node_index: dict[str, int]
pipe_index: dict[str, int]
content: bytes
flow_factor: float
pressure_factor: float
velocity_factor: float
def result_factors(units: dict[str, str]) -> tuple[float, float, float]:
"""Match EPANET outputs to m³/h, metres of water, and m/s."""
flow = units["flow"].upper()
flow_factors = {"LPS": 3.6, "LPM": .06, "MLD": 1000 / 24,
"CMH": 1, "CMD": 1 / 24, "CFS": .028316846592 * 3600,
"GPM": .003785411784 * 60, "MGD": 3785.411784 / 24,
"IMGD": 4546.09 / 24, "AFD": 1233.48183754752 / 24}
pressure = units["pressure"].upper()
pressure_factors = {"M": 1, "METERS": 1, "PSI": .7032496149, "KPA": .1019716213}
if flow not in flow_factors or pressure not in pressure_factors:
raise ValueError("不支持的模拟结果单位")
return (flow_factors[flow], pressure_factors[pressure],
.3048 if flow in {"CFS", "GPM", "MGD", "IMGD", "AFD"} else 1)
def line_paths(geometry: dict) -> list:
if geometry["type"] == "LineString":
return [geometry["coordinates"]]
if geometry["type"] == "MultiLineString":
return geometry["coordinates"]
if geometry["type"] == "GeometryCollection":
# PostGIS LineMerge returns an empty collection for zero-length lines.
return [path for child in geometry["geometries"] for path in line_paths(child)]
raise ValueError("管线几何必须为线类型")
def build_geometry(nodes: list[dict], pipes: list[dict], units: dict[str, str]) -> PlaybackGeometry:
if not nodes or not pipes:
raise ValueError("管网没有可发布的节点或管线几何")
node_ids = tuple(str(row["id"]) for row in nodes)
pipe_ids = tuple(str(row["id"]) for row in pipes)
node_positions = [mercator(float(row["longitude"]), float(row["latitude"])) for row in nodes]
positions_by_id = dict(zip(node_ids, node_positions))
origin = [(min(p[i] for p in node_positions) + max(p[i] for p in node_positions)) / 2 for i in (0, 1)]
values = array("f", (coordinate - origin[axis] for position in node_positions for axis, coordinate in enumerate(position)))
segment_count = 0
for index, row in enumerate(pipes):
geometry = row["geometry"]
if isinstance(geometry, str):
geometry = json.loads(geometry)
paths = line_paths(geometry)
start_node = positions_by_id.get(str(row.get("start_node_id", "")))
if start_node and paths and paths[0] and paths[-1]:
first = mercator(*paths[0][0][:2])
last = mercator(*paths[-1][-1][:2])
if math.dist(last, start_node) < math.dist(first, start_node):
paths = [list(reversed(path)) for path in reversed(paths)]
distance = 0.0
for path in paths:
positions = [mercator(float(p[0]), float(p[1])) for p in path]
for start, end in zip(positions, positions[1:]):
length = math.hypot(end[0] - start[0], end[1] - start[1])
if length <= 0:
continue
# Instanced segment: endpoints relative to origin, pipe index,
# cumulative distance, segment length, physical diameter (mm).
values.extend((start[0] - origin[0], start[1] - origin[1],
end[0] - origin[0], end[1] - origin[1],
index, distance, length, float(row["diameter"] or 0)))
distance += length
segment_count += 1
factors = result_factors(units)
descriptor = json.dumps([node_ids, pipe_ids, origin, units], separators=(",", ":")).encode()
version = hashlib.sha256(descriptor + values.tobytes()).hexdigest()[:24]
header = {"kind": "geometry", "protocol": 1, "modelVersion": version,
"nodeIds": node_ids, "pipeIds": pipe_ids, "origin": origin,
"segmentCount": segment_count, "units": {"pressure": "m", "flow": "m³/h", "velocity": "m/s"}}
return PlaybackGeometry(version, node_ids, pipe_ids,
{key: i for i, key in enumerate(node_ids)},
{key: i for i, key in enumerate(pipe_ids)},
packet(header, values), *factors)
async def read_geometry(conn: AsyncConnection) -> PlaybackGeometry:
async with conn.cursor() as cursor:
await cursor.execute("""
SELECT n.id, ST_X(ST_Transform(g.geom,4326)) AS longitude,
ST_Y(ST_Transform(g.geom,4326)) AS latitude
FROM gis.network_nodes n JOIN gis.node_geometries g ON g.node_id=n.id
WHERE g.geom IS NOT NULL AND NOT ST_IsEmpty(g.geom) ORDER BY n.id
""")
nodes = await cursor.fetchall()
await cursor.execute("""
SELECT id, start_node_id, diameter, ST_AsGeoJSON(ST_Transform(ST_LineMerge(geom),4326))::json AS geometry
FROM gis.pipes WHERE geom IS NOT NULL AND NOT ST_IsEmpty(geom) ORDER BY id
""")
pipes = await cursor.fetchall()
units = await NetworkSettingsRepository.get_result_units(conn)
return await asyncio.to_thread(build_geometry, nodes, pipes, units)
def finite(value: Any, factor: float) -> float:
if value is None:
return math.nan
result = float(value) * factor
return result if math.isfinite(result) and abs(result) <= 3.402823466e38 else math.nan
async def read_frames(conn: AsyncConnection, geometry: PlaybackGeometry, start: datetime,
frame_count: int, step_seconds: int) -> bytes:
times = [start + timedelta(seconds=step_seconds * i) for i in range(frame_count)]
node_count, pipe_count = len(geometry.node_ids), len(geometry.pipe_ids)
stride = node_count + 2 * pipe_count
values = array("f", [math.nan]) * (stride * frame_count)
time_index = {value: i for i, value in enumerate(times)}
valid_counts = {metric: [0] * frame_count for metric in ("pressure", "flow", "velocity")}
async with conn.cursor() as cursor:
await cursor.execute("""
SELECT time, node_id, pressure FROM realtime.node_results
WHERE time = ANY(%s) ORDER BY time, node_id
""", (times,))
async for row in cursor:
index = geometry.node_index.get(str(row["node_id"]))
if index is not None:
frame = time_index[row["time"]]
values[frame * stride + index] = finite(row["pressure"], geometry.pressure_factor)
valid_counts["pressure"][frame] += math.isfinite(values[frame * stride + index])
await cursor.execute("""
SELECT time, link_id, flow, velocity FROM realtime.link_results
WHERE time = ANY(%s) ORDER BY time, link_id
""", (times,))
async for row in cursor:
index = geometry.pipe_index.get(str(row["link_id"]))
if index is not None:
frame = time_index[row["time"]]
offset = frame * stride + node_count
values[offset + index] = finite(row["flow"], geometry.flow_factor)
values[offset + pipe_count + index] = finite(row["velocity"], geometry.velocity_factor)
valid_counts["flow"][frame] += math.isfinite(values[offset + index])
valid_counts["velocity"][frame] += math.isfinite(values[offset + pipe_count + index])
return packet({"kind": "frames", "protocol": 1, "modelVersion": geometry.version,
"nodeCount": node_count, "pipeCount": pipe_count,
"times": [int(t.timestamp() * 1000) for t in times], "validCounts": valid_counts,
"available": [valid_counts["pressure"][i] > 0 and valid_counts["flow"][i] > 0 for i in range(frame_count)]}, values)
@@ -0,0 +1,79 @@
from types import SimpleNamespace
from uuid import uuid4
import asyncio
from fastapi.testclient import TestClient
import pytest
from app.api.v1.endpoints.timeseries import playback
from app.auth.project_dependencies import ProjectContext
from tests.conftest import build_test_app
def client(monkeypatch):
context = ProjectContext(project_id=uuid4(), project_code="test", user_id=uuid4(), project_role="viewer")
app = build_test_app(playback.router, "/api/v1")
app.dependency_overrides[playback.get_project_context] = lambda: context
app.dependency_overrides[playback.get_project_pg_connection] = lambda: object()
app.dependency_overrides[playback.get_timescale_connection] = lambda: object()
async def geometry(*_):
return SimpleNamespace(version="a" * 24, content=b"geometry")
monkeypatch.setattr(playback, "geometry_for_project", geometry)
return TestClient(app)
def test_geometry_is_private_and_frames_reject_a_different_model_version(monkeypatch):
http = client(monkeypatch)
response = http.get("/api/v1/timeseries/realtime/playback/geometry")
assert response.status_code == 200
assert response.headers["cache-control"] == "private, no-store"
assert response.content == b"geometry"
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={
"start_time": "2026-07-16T00:00:00+08:00", "model_version": "b" * 24,
})
assert response.status_code == 409
@pytest.mark.parametrize("params", [
{"start_time": "2026-07-16T00:00:00"},
{"start_time": "2026-07-16T00:00:00Z", "frame_count": 9},
{"start_time": "2026-07-16T00:00:00Z", "step_seconds": 0},
])
def test_frames_require_timezone_and_bound_the_block_size(monkeypatch, params):
http = client(monkeypatch)
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={"model_version": "a" * 24, **params})
assert response.status_code == 422
def test_frames_pass_exact_utc_time_and_counts_to_reader(monkeypatch):
http = client(monkeypatch)
async def frames(_connection, geometry, start, count, step):
assert geometry.version == "a" * 24
assert start.isoformat() == "2026-07-15T16:00:00+00:00"
assert count == 4 and step == 900
return b"frames"
monkeypatch.setattr(playback, "read_frames", frames)
response = http.get("/api/v1/timeseries/realtime/playback/frames", params={
"start_time": "2026-07-16T00:00:00+08:00", "model_version": "a" * 24,
})
assert response.status_code == 200 and response.content == b"frames"
def test_cache_separates_projects_and_database_routes(monkeypatch):
playback._cache.clear()
calls = []
async def read(conn):
calls.append(conn.info.dbname)
return SimpleNamespace(version=conn.info.dbname)
monkeypatch.setattr(playback, "read_geometry", read)
ctx = SimpleNamespace(project_id=uuid4())
async def run():
for name in ["one", "one", "two"]:
connection = SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname=name))
result = await playback.geometry_for_project(ctx, connection)
assert result.version == name
other = SimpleNamespace(project_id=uuid4())
await playback.geometry_for_project(other, SimpleNamespace(info=SimpleNamespace(host="host", port=5432, dbname="one")))
asyncio.run(run())
assert calls == ["one", "two", "one"]
playback._cache.clear()
+93
View File
@@ -0,0 +1,93 @@
import asyncio
from datetime import datetime, timezone
import json
import math
import struct
import pytest
from app.services.hydraulic_playback import build_geometry, read_frames, result_factors
def fixture_geometry(longitude=121.5):
return build_geometry([
{"id": "J1", "longitude": longitude, "latitude": 30.8},
{"id": "J2", "longitude": longitude + .001, "latitude": 30.8},
], [{"id": "P1", "start_node_id": "J1", "diameter": 600,
"geometry": {"type": "LineString", "coordinates": [[longitude + .001, 30.8], [longitude, 30.8]]}}],
{"flow": "LPS", "pressure": "METERS"})
def unpack(content):
length = struct.unpack_from("<I", content)[0]
header = json.loads(content[4:4 + length])
count = (len(content) - 4 - length) // 4
return header, struct.unpack_from(f"<{count}f", content, 4 + length)
def test_geometry_version_tracks_absolute_location_and_orients_geometry_with_start_node():
geometry = fixture_geometry()
header, values = unpack(geometry.content)
assert header["nodeIds"] == ["J1", "J2"]
assert header["pipeIds"] == ["P1"]
assert header["segmentCount"] == 1
assert values[4:6] == values[:2] # reversed GIS geometry is oriented J1 -> J2
assert values[6:8] == values[2:4]
assert fixture_geometry().version == geometry.version
assert fixture_geometry(121.6).version != geometry.version
def test_unit_factors_preserve_signed_flow_and_reject_unknown_units():
assert result_factors({"flow": "LPS", "pressure": "METERS"}) == (3.6, 1, 1)
flow, pressure, velocity = result_factors({"flow": "GPM", "pressure": "PSI"})
assert flow == pytest.approx(.003785411784 * 60)
assert pressure == pytest.approx(.7032496149)
assert velocity == .3048
with pytest.raises(ValueError):
result_factors({"flow": "unknown", "pressure": "METERS"})
class Cursor:
def __init__(self, time):
self.time = time
self.rows = []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
pass
async def execute(self, query, params):
assert len(params[0]) == 2
if "node_results" in query:
self.rows = [{"time": self.time, "node_id": "J1", "pressure": 25},
{"time": self.time, "node_id": "J2", "pressure": None}]
else:
self.rows = [{"time": self.time, "link_id": "P1", "flow": -10, "velocity": .7}]
def __aiter__(self):
self.iterator = iter(self.rows)
return self
async def __anext__(self):
try:
return next(self.iterator)
except StopIteration:
raise StopAsyncIteration
class Connection:
def __init__(self, time):
self.time = time
def cursor(self):
return Cursor(self.time)
def test_frames_pack_exact_sample_times_signed_values_and_missing_values_without_extrapolation():
time = datetime(2026, 7, 15, 16, tzinfo=timezone.utc)
content = asyncio.run(read_frames(Connection(time), fixture_geometry(), time, 2, 900))
header, values = unpack(content)
assert header["times"] == [1784131200000, 1784132100000]
assert header["available"] == [True, False]
assert header["validCounts"]["pressure"] == [1, 0]
assert values[0] == 25
assert math.isnan(values[1])
assert values[2] == -36
assert values[3] == pytest.approx(.7)
assert all(math.isnan(value) for value in values[4:])