feat(timeseries): unify element history queries
This commit is contained in:
@@ -159,6 +159,7 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch):
|
||||
lambda name: json.dumps(
|
||||
{
|
||||
"output": {
|
||||
"units": {"flow": "LPS", "pressure": "MTR"},
|
||||
"times": {"num_periods": 2, "report_step": 900},
|
||||
"node_results": [{"node": "J1", "result": [{}, {}]}],
|
||||
"link_results": [{"link": "P1", "result": [{}, {}]}],
|
||||
@@ -167,7 +168,12 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch):
|
||||
),
|
||||
)
|
||||
lifecycle_calls: list[tuple] = []
|
||||
monkeypatch.setattr(simulation, "create_analysis_run", lambda **kwargs: run_id)
|
||||
create_calls: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
simulation,
|
||||
"create_analysis_run",
|
||||
lambda **kwargs: (create_calls.append(kwargs), run_id)[1],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
simulation,
|
||||
"update_analysis_run",
|
||||
@@ -195,6 +201,10 @@ def test_extended_simulation_stores_results_by_run_id(monkeypatch):
|
||||
assert kwargs["db_name"] == "demo"
|
||||
assert returned_run_id == run_id
|
||||
assert lifecycle_calls[-1][1]["status"] == "completed"
|
||||
assert create_calls[0]["scheme_detail"]["result_units"] == {
|
||||
"flow": "LPS",
|
||||
"pressure": "MTR",
|
||||
}
|
||||
assert transaction_calls == [("begin", "demo"), ("end", "demo")]
|
||||
refresh_mock.assert_called_once_with("demo")
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from app.domain.measurement_units import display_unit_for_metric
|
||||
|
||||
|
||||
def test_display_units_are_stable_ui_units():
|
||||
assert display_unit_for_metric("flow") == "m³/h"
|
||||
assert display_unit_for_metric("pressure") == "m"
|
||||
assert display_unit_for_metric("velocity") == "m/s"
|
||||
@@ -29,6 +29,7 @@ class _FakeCursor:
|
||||
"node_id": " J1 ",
|
||||
"link_id": None,
|
||||
"api_query_id": "query-1",
|
||||
"measurement_unit": "m",
|
||||
"transmission_mode": "realtime",
|
||||
"transmission_frequency": None,
|
||||
"reliability": "95",
|
||||
@@ -63,6 +64,7 @@ def test_get_scadas_normalizes_id_and_type():
|
||||
"node_id": "J1",
|
||||
"link_id": None,
|
||||
"api_query_id": "query-1",
|
||||
"measurement_unit": "m",
|
||||
"transmission_mode": "realtime",
|
||||
"transmission_frequency": None,
|
||||
"reliability": 95,
|
||||
|
||||
@@ -13,6 +13,7 @@ PROJECT_SCADA = {
|
||||
"node_id": "J1",
|
||||
"link_id": None,
|
||||
"api_query_id": "query-1",
|
||||
"measurement_unit": "m",
|
||||
"transmission_mode": "realtime",
|
||||
"transmission_frequency": None,
|
||||
"reliability": 1.0,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.schemas.timeseries_history import ElementHistoryQuery
|
||||
from app.services import timeseries_history
|
||||
|
||||
|
||||
START = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
END = START + timedelta(hours=1)
|
||||
|
||||
|
||||
def test_realtime_history_batches_devices_and_converts_units(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.ScadaInfoRepository,
|
||||
"get_scadas_for_elements",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"device_id": "F-1",
|
||||
"device_type": "pipe_flow",
|
||||
"link_id": "P-1",
|
||||
"node_id": None,
|
||||
"measurement_unit": "m3/h",
|
||||
},
|
||||
{
|
||||
"device_id": "P-1A",
|
||||
"device_type": "pressure",
|
||||
"link_id": None,
|
||||
"node_id": "J-1",
|
||||
"measurement_unit": "m",
|
||||
},
|
||||
{
|
||||
"device_id": "P-1B",
|
||||
"device_type": "pressure",
|
||||
"link_id": None,
|
||||
"node_id": "J-1",
|
||||
"measurement_unit": "m",
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
scada_query = AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"time": START,
|
||||
"device_id": "F-1",
|
||||
"monitored_value": 36.0,
|
||||
"cleaned_value": 35.0,
|
||||
},
|
||||
{
|
||||
"time": START,
|
||||
"device_id": "P-1A",
|
||||
"monitored_value": 20.0,
|
||||
"cleaned_value": 21.0,
|
||||
},
|
||||
{
|
||||
"time": START,
|
||||
"device_id": "P-1B",
|
||||
"monitored_value": 22.0,
|
||||
"cleaned_value": 23.0,
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.ScadaRepository,
|
||||
"get_scada_by_ids_time_range",
|
||||
scada_query,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.NetworkSettingsRepository,
|
||||
"get_result_units",
|
||||
AsyncMock(return_value={"flow": "MLD", "pressure": "METERS"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.RealtimeRepository,
|
||||
"get_link_fields_by_ids_time_range",
|
||||
AsyncMock(return_value={"P-1": [{"time": START, "value": 2.0}]}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.RealtimeRepository,
|
||||
"get_node_fields_by_ids_time_range",
|
||||
AsyncMock(return_value={"J-1": [{"time": START, "value": 24.0}]}),
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
timeseries_history.TimeseriesHistoryService.query(
|
||||
object(),
|
||||
object(),
|
||||
ElementHistoryQuery(
|
||||
start_time=START,
|
||||
end_time=END,
|
||||
mode="realtime_comparison",
|
||||
elements=[
|
||||
{"element_id": "P-1", "element_type": "pipe"},
|
||||
{"element_id": "J-1", "element_type": "junction"},
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert scada_query.await_count == 1
|
||||
assert set(scada_query.await_args.args[1]) == {"F-1", "P-1A", "P-1B"}
|
||||
assert {item.device_id for item in result.series if item.device_id} == {
|
||||
"F-1",
|
||||
"P-1A",
|
||||
"P-1B",
|
||||
}
|
||||
simulation = {
|
||||
(item.element_id, item.metric.value): item
|
||||
for item in result.series
|
||||
if item.source.value == "realtime_simulation"
|
||||
}
|
||||
assert simulation[("P-1", "flow")].points[0].value == 2.0
|
||||
assert simulation[("P-1", "flow")].source_unit == "MLD"
|
||||
assert simulation[("J-1", "pressure")].points[0].value == 24.0
|
||||
assert all(item.display_unit in {"m³/h", "m"} for item in result.series)
|
||||
|
||||
|
||||
def test_requested_device_must_belong_to_element(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
timeseries_history.ScadaInfoRepository,
|
||||
"get_scadas_for_elements",
|
||||
AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="do not belong"):
|
||||
asyncio.run(
|
||||
timeseries_history.TimeseriesHistoryService.query(
|
||||
object(),
|
||||
object(),
|
||||
ElementHistoryQuery(
|
||||
start_time=START,
|
||||
end_time=END,
|
||||
elements=[
|
||||
{
|
||||
"element_id": "J-1",
|
||||
"element_type": "junction",
|
||||
"device_ids": ["missing"],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas.timeseries_history import ElementHistoryQuery
|
||||
|
||||
|
||||
def _query(**overrides):
|
||||
start = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
values = {
|
||||
"start_time": start,
|
||||
"end_time": start + timedelta(hours=1),
|
||||
"mode": "observed",
|
||||
"elements": [{"element_id": " P1 ", "element_type": "pipe"}],
|
||||
}
|
||||
values.update(overrides)
|
||||
return ElementHistoryQuery(**values)
|
||||
|
||||
|
||||
def test_history_query_normalizes_targets_and_device_ids():
|
||||
query = _query(
|
||||
elements=[
|
||||
{
|
||||
"element_id": " P1 ",
|
||||
"element_type": "pipe",
|
||||
"device_ids": [" D1 ", "D1", "D2"],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert query.elements[0].element_id == "P1"
|
||||
assert query.elements[0].device_ids == ["D1", "D2"]
|
||||
|
||||
|
||||
def test_analysis_comparison_requires_run_id():
|
||||
with pytest.raises(ValidationError, match="run_id is required"):
|
||||
_query(mode="analysis_comparison")
|
||||
|
||||
|
||||
def test_other_modes_reject_run_id():
|
||||
with pytest.raises(ValidationError, match="only valid"):
|
||||
_query(run_id=uuid4())
|
||||
|
||||
|
||||
def test_history_query_rejects_reversed_time_range():
|
||||
start = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
with pytest.raises(ValidationError, match="start_time must be earlier"):
|
||||
_query(start_time=start, end_time=start)
|
||||
Reference in New Issue
Block a user