feat(api): add compact hydraulic playback endpoints
This commit is contained in:
@@ -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()
|
||||
@@ -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:])
|
||||
Reference in New Issue
Block a user