feat(timeseries): unify element history queries
Generic Container CI/CD / test-build-publish (push) Successful in 2m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m10s

This commit is contained in:
2026-09-14 12:30:41 +08:00
parent 0685f6dd17
commit 682c26fddd
17 changed files with 888 additions and 531 deletions
+17 -166
View File
@@ -1,183 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from datetime import datetime
from psycopg import AsyncConnection
from uuid import UUID
from app.services.timeseries_analysis import TimeseriesAnalysisService
from app.domain.schemas.timeseries_history import (
ElementHistoryQuery,
ElementHistoryResponse,
)
from app.services.timeseries_history import TimeseriesHistoryService
from .dependencies import get_timescale_connection, get_postgres_connection
router = APIRouter()
@router.get("/timeseries/views/scada-simulations", summary="获取SCADA关联的模拟数据")
async def get_scada_associated_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
device_ids: str = Query(..., description="SCADA设备ID列表,逗号分隔"),
run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"),
@router.post(
"/timeseries/views/element-history/query",
summary="批量查询管网元素历史数据",
response_model=ElementHistoryResponse,
)
async def query_element_history(
payload: ElementHistoryQuery,
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
获取SCADA关联的link/node模拟值
根据传入的SCADA device_ids,找到关联的link/node
并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
device_ids: SCADA设备ID列表,用逗号分隔
run_id: 分析运行 ID,若为空则查询实时数据
timescale_conn: TimescaleDB连接
postgres_conn: PostgreSQL连接
Returns:
SCADA关联的模拟数据
Raises:
HTTPException: 当查询参数无效时返回400错误,未找到数据时返回404错误
"""
) -> ElementHistoryResponse:
try:
device_ids_list = (
[id.strip() for id in device_ids.split(",") if id.strip()]
if device_ids
else []
return await TimeseriesHistoryService.query(
timescale_conn, postgres_conn, payload
)
if run_id is not None:
result = await TimeseriesAnalysisService.get_scada_associated_analysis_simulation_data(
timescale_conn,
postgres_conn,
device_ids_list,
start_time,
end_time,
run_id,
)
else:
result = (
await TimeseriesAnalysisService.get_scada_associated_realtime_simulation_data(
timescale_conn,
postgres_conn,
device_ids_list,
start_time,
end_time,
)
)
if result is None:
raise HTTPException(status_code=404, detail="No simulation data found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/timeseries/views/element-simulations", summary="获取管网元素的模拟数据")
async def get_feature_simulation_data(
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
feature_infos: str = Query(
..., description="特征信息,格式: id1:type1,id2:type2type为pipe(管道)或junction(节点)"
),
run_id: UUID | None = Query(None, description="分析运行 ID;为空时查询实时数据"),
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
):
"""
获取link/node模拟值
根据传入的featureInfos,找到关联的link/node
并根据对应的type,查询对应的模拟数据。支持查询实时或分析运行数据。
Args:
start_time: 查询开始时间
end_time: 查询结束时间
feature_infos: 格式为 "element_id1:type1,element_id2:type2"
例如: "P1:pipe,J1:junction"
run_id: 分析运行 ID,若为空则查询实时数据
timescale_conn: TimescaleDB连接
Returns:
管网元素的模拟数据
Raises:
HTTPException: 当feature_infos为空返回400错误,未找到数据返回404错误,其他错误返回400错误
"""
try:
feature_infos_list = []
if feature_infos:
for item in feature_infos.split(","):
item = item.strip()
if ":" in item:
element_id, element_type = item.split(":", 1)
feature_infos_list.append(
(element_id.strip(), element_type.strip())
)
if not feature_infos_list:
raise HTTPException(status_code=400, detail="feature_infos cannot be empty")
if run_id is not None:
result = await TimeseriesAnalysisService.get_analysis_simulation_data(
timescale_conn,
feature_infos_list,
start_time,
end_time,
run_id,
)
else:
result = await TimeseriesAnalysisService.get_realtime_simulation_data(
timescale_conn,
feature_infos_list,
start_time,
end_time,
)
if result is None:
raise HTTPException(status_code=404, detail="No simulation data found")
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/timeseries/views/element-scada-readings", summary="获取管网元素关联的SCADA监测数据")
async def get_element_associated_scada_data(
element_id: str = Query(..., description="管网元素ID(管道或节点)"),
start_time: datetime = Query(..., description="查询开始时间"),
end_time: datetime = Query(..., description="查询结束时间"),
use_cleaned: bool = Query(False, description="是否使用清洗后的数据"),
timescale_conn: AsyncConnection = Depends(get_timescale_connection),
postgres_conn: AsyncConnection = Depends(get_postgres_connection),
):
"""
获取link/node关联的SCADA监测值
根据传入的link/node id,匹配SCADA信息,
如果存在关联的SCADA device_id,获取实际的监测数据。
Args:
element_id: 管网元素ID
start_time: 查询开始时间
end_time: 查询结束时间
use_cleaned: 是否使用清洗后的数据,默认为False使用原始数据
timescale_conn: TimescaleDB连接
postgres_conn: PostgreSQL连接
Returns:
管网元素关联的SCADA监测数据
Raises:
HTTPException: 当查询参数无效时返回400错误,未找到关联数据返回404错误
"""
try:
result = await TimeseriesAnalysisService.get_element_associated_scada_data(
timescale_conn, postgres_conn, element_id, start_time, end_time, use_cleaned
)
if result is None:
raise HTTPException(
status_code=404, detail="No associated SCADA data found"
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/timeseries/scada-cleaning-runs", summary="清洗SCADA监测数据")
+13
View File
@@ -0,0 +1,13 @@
from typing import Literal
Metric = Literal["flow", "pressure", "velocity"]
DISPLAY_UNITS: dict[Metric, str] = {
"flow": "m³/h",
"pressure": "m",
"velocity": "m/s",
}
def display_unit_for_metric(metric: Metric) -> str:
return DISPLAY_UNITS[metric]
+1
View File
@@ -9,6 +9,7 @@ class ScadaDeviceResponse(BaseModel):
node_id: str | None = None
link_id: str | None = None
api_query_id: str | None = None
measurement_unit: str
transmission_mode: str
transmission_frequency: str
reliability: int | None = None
+88
View File
@@ -0,0 +1,88 @@
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import BaseModel, Field, field_validator, model_validator
class HistoryElementType(StrEnum):
PIPE = "pipe"
JUNCTION = "junction"
class HistoryMode(StrEnum):
OBSERVED = "observed"
REALTIME_COMPARISON = "realtime_comparison"
ANALYSIS_COMPARISON = "analysis_comparison"
class HistoryMetric(StrEnum):
FLOW = "flow"
PRESSURE = "pressure"
class HistorySource(StrEnum):
SCADA_RAW = "scada_raw"
SCADA_CLEANED = "scada_cleaned"
REALTIME_SIMULATION = "realtime_simulation"
ANALYSIS_SIMULATION = "analysis_simulation"
class ElementHistoryTarget(BaseModel):
element_id: str = Field(min_length=1, max_length=128)
element_type: HistoryElementType
device_ids: list[str] | None = Field(default=None, max_length=100)
@field_validator("element_id")
@classmethod
def normalize_element_id(cls, value: str) -> str:
return value.strip()
@field_validator("device_ids")
@classmethod
def normalize_device_ids(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = list(dict.fromkeys(item.strip() for item in value if item.strip()))
if not normalized:
raise ValueError("device_ids must contain at least one device")
return normalized
class ElementHistoryQuery(BaseModel):
start_time: datetime
end_time: datetime
mode: HistoryMode = HistoryMode.OBSERVED
run_id: UUID | None = None
elements: list[ElementHistoryTarget] = Field(min_length=1, max_length=200)
@model_validator(mode="after")
def validate_query(self) -> "ElementHistoryQuery":
if self.start_time >= self.end_time:
raise ValueError("start_time must be earlier than end_time")
if self.mode == HistoryMode.ANALYSIS_COMPARISON and self.run_id is None:
raise ValueError("run_id is required for analysis_comparison")
if self.mode != HistoryMode.ANALYSIS_COMPARISON and self.run_id is not None:
raise ValueError("run_id is only valid for analysis_comparison")
return self
class HistoryPoint(BaseModel):
time: datetime
value: float | None
class ElementHistorySeries(BaseModel):
element_id: str
element_type: HistoryElementType
device_id: str | None = None
metric: HistoryMetric
source: HistorySource
source_unit: str = Field(description="Unit used by the returned point values")
display_unit: str = Field(description="Recommended UI display unit")
unit_inferred: bool = False
points: list[HistoryPoint]
class ElementHistoryResponse(BaseModel):
series: list[ElementHistorySeries]
@@ -0,0 +1,32 @@
from typing import Any
from psycopg import AsyncConnection
class NetworkSettingsRepository:
@staticmethod
async def get_result_units(conn: AsyncConnection) -> dict[str, str]:
async with conn.cursor() as cur:
await cur.execute(
"""
SELECT engine_version, key, value
FROM network.simulation_settings
WHERE (engine_version = 'v3' AND key IN ('FLOW_UNITS', 'PRESSURE_UNITS'))
OR (engine_version = 'legacy' AND key IN ('UNITS', 'PRESSURE'))
ORDER BY CASE engine_version WHEN 'v3' THEN 0 ELSE 1 END
"""
)
rows: list[dict[str, Any]] = await cur.fetchall()
units: dict[str, str] = {}
for row in rows:
key = str(row["key"])
metric = "flow" if key in {"FLOW_UNITS", "UNITS"} else "pressure"
units.setdefault(metric, str(row["value"]).strip())
missing = {"flow", "pressure"} - units.keys()
if missing:
raise ValueError(
"network simulation settings missing result units: "
+ ", ".join(sorted(missing))
)
return units
+19 -1
View File
@@ -9,7 +9,7 @@ from app.native.wndb.core.database import read_all, try_read
_SCADA_VIEW_SELECT = """
SELECT id AS device_id, device_type, node_id, link_id, api_query_id,
transmission_mode, transmission_frequency, reliability, x, y,
measurement_unit, transmission_mode, transmission_frequency, reliability, x, y,
ST_X(ST_Transform(geom, 4326)) AS longitude,
ST_Y(ST_Transform(geom, 4326)) AS latitude
FROM gis.scada_devices
@@ -57,6 +57,7 @@ def _device(record: dict[str, Any]) -> dict[str, Any]:
"node_id": _optional_text(record["node_id"]),
"link_id": _optional_text(record["link_id"]),
"api_query_id": _optional_text(record["api_query_id"]),
"measurement_unit": str(record["measurement_unit"]).strip(),
"transmission_mode": record["transmission_mode"],
"transmission_frequency": record["transmission_frequency"],
"reliability": _optional_int(record["reliability"]),
@@ -105,6 +106,22 @@ class ScadaInfoRepository:
)
return {str(row["device_id"]).strip() for row in await cur.fetchall()}
@staticmethod
async def get_scadas_for_elements(
conn: AsyncConnection,
node_ids: list[str],
link_ids: list[str],
) -> list[dict[str, Any]]:
if not node_ids and not link_ids:
return []
async with conn.cursor() as cur:
await cur.execute(
_SCADA_VIEW_SELECT
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id",
(node_ids, link_ids),
)
return [_device(record) for record in await cur.fetchall()]
def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]:
return {
@@ -113,6 +130,7 @@ def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]:
"node_id": {"type": "str", "optional": True, "readonly": True},
"link_id": {"type": "str", "optional": True, "readonly": True},
"api_query_id": {"type": "str", "optional": True, "readonly": True},
"measurement_unit": {"type": "str", "optional": False, "readonly": True},
"transmission_mode": {"type": "str", "optional": False, "readonly": True},
"transmission_frequency": {"type": "str", "optional": False, "readonly": True},
"reliability": {"type": "int", "optional": False, "readonly": True},
+8 -1
View File
@@ -532,7 +532,14 @@ def run_simulation(
raise RuntimeError("run_project output missing times.report_step")
if not scheme_type or not scheme_name:
raise ValueError("extended simulation requires analysis run type and name")
detail = scheme_detail or {}
detail = dict(scheme_detail or {})
result_units = output_data.get("units")
if isinstance(result_units, dict):
detail["result_units"] = {
key: str(result_units[key]).strip()
for key in ("flow", "pressure")
if result_units.get(key)
}
run_id = create_analysis_run(
name=db_name,
scheme_name=scheme_name,
+237
View File
@@ -0,0 +1,237 @@
from collections import defaultdict
from typing import Any
from psycopg import AsyncConnection
from app.domain.measurement_units import display_unit_for_metric
from app.domain.schemas.timeseries_history import (
ElementHistoryQuery,
ElementHistoryResponse,
ElementHistorySeries,
HistoryElementType,
HistoryMetric,
HistoryPoint,
HistorySource,
)
from app.infra.db.postgresql.analysis import AnalysisRepository
from app.infra.db.postgresql.network_settings import NetworkSettingsRepository
from app.infra.db.postgresql.scada import ScadaInfoRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
def _target_metric(element_type: HistoryElementType) -> HistoryMetric:
return (
HistoryMetric.FLOW
if element_type == HistoryElementType.PIPE
else HistoryMetric.PRESSURE
)
def _device_metric(device_type: str) -> HistoryMetric | None:
normalized = device_type.strip().lower()
if normalized in {"pipe_flow", "flow"}:
return HistoryMetric.FLOW
if normalized == "pressure":
return HistoryMetric.PRESSURE
return None
def _points(
rows: list[dict[str, Any]],
*,
value_field: str,
) -> list[HistoryPoint]:
return [
HistoryPoint(
time=row["time"],
value=(
float(row[value_field])
if row.get(value_field) is not None
else None
),
)
for row in rows
]
class TimeseriesHistoryService:
@staticmethod
async def query(
timescale_conn: AsyncConnection,
postgres_conn: AsyncConnection,
query: ElementHistoryQuery,
) -> ElementHistoryResponse:
pipe_ids = list(
dict.fromkeys(
target.element_id
for target in query.elements
if target.element_type == HistoryElementType.PIPE
)
)
junction_ids = list(
dict.fromkeys(
target.element_id
for target in query.elements
if target.element_type == HistoryElementType.JUNCTION
)
)
devices = await ScadaInfoRepository.get_scadas_for_elements(
postgres_conn, junction_ids, pipe_ids
)
devices_by_element: dict[tuple[HistoryElementType, str], list[dict[str, Any]]] = (
defaultdict(list)
)
for device in devices:
if device.get("link_id") in pipe_ids:
devices_by_element[(HistoryElementType.PIPE, device["link_id"])].append(
device
)
if device.get("node_id") in junction_ids:
devices_by_element[
(HistoryElementType.JUNCTION, device["node_id"])
].append(device)
selected_devices: dict[str, tuple[dict[str, Any], HistoryElementType, str]] = {}
for target in query.elements:
target_devices = devices_by_element.get(
(target.element_type, target.element_id), []
)
if target.device_ids is not None:
target_device_ids = {device["device_id"] for device in target_devices}
unknown = set(target.device_ids) - target_device_ids
if unknown:
raise ValueError(
f"SCADA devices do not belong to {target.element_type.value} "
f"{target.element_id}: {', '.join(sorted(unknown))}"
)
requested = set(target.device_ids)
target_devices = [
device
for device in target_devices
if device["device_id"] in requested
]
expected_metric = _target_metric(target.element_type)
for device in target_devices:
if _device_metric(device["device_type"]) != expected_metric:
continue
selected_devices[device["device_id"]] = (
device,
target.element_type,
target.element_id,
)
scada_rows = await ScadaRepository.get_scada_by_ids_time_range(
timescale_conn,
list(selected_devices),
query.start_time,
query.end_time,
)
scada_by_device: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in scada_rows:
scada_by_device[str(row["device_id"])].append(row)
series: list[ElementHistorySeries] = []
for device_id, (device, element_type, element_id) in selected_devices.items():
rows = scada_by_device.get(device_id, [])
if not rows:
continue
metric = _target_metric(element_type)
unit = device["measurement_unit"]
for source, field in (
(HistorySource.SCADA_RAW, "monitored_value"),
(HistorySource.SCADA_CLEANED, "cleaned_value"),
):
series.append(
ElementHistorySeries(
element_id=element_id,
element_type=element_type,
device_id=device_id,
metric=metric,
source=source,
source_unit=unit,
display_unit=display_unit_for_metric(metric.value),
points=_points(
rows,
value_field=field,
),
)
)
if query.mode.value == "observed":
return ElementHistoryResponse(series=series)
current_units = await NetworkSettingsRepository.get_result_units(postgres_conn)
if query.mode.value == "analysis_comparison":
run = await AnalysisRepository.get_run(postgres_conn, query.run_id)
if run is None:
raise ValueError(f"analysis run does not exist: {query.run_id}")
parameters = run.get("parameters") or {}
recorded_units = parameters.get("result_units")
unit_inferred = not isinstance(recorded_units, dict)
result_units = recorded_units if not unit_inferred else current_units
link_values = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn,
query.run_id,
"link",
pipe_ids,
query.start_time,
query.end_time,
"flow",
)
node_values = await AnalysisResultsRepository.get_series_by_ids(
timescale_conn,
query.run_id,
"node",
junction_ids,
query.start_time,
query.end_time,
"pressure",
)
source = HistorySource.ANALYSIS_SIMULATION
else:
unit_inferred = False
result_units = current_units
link_values = await RealtimeRepository.get_link_fields_by_ids_time_range(
timescale_conn,
query.start_time,
query.end_time,
pipe_ids,
"flow",
)
node_values = await RealtimeRepository.get_node_fields_by_ids_time_range(
timescale_conn,
query.start_time,
query.end_time,
junction_ids,
"pressure",
)
source = HistorySource.REALTIME_SIMULATION
for target in query.elements:
metric = _target_metric(target.element_type)
rows = (
link_values.get(target.element_id, [])
if target.element_type == HistoryElementType.PIPE
else node_values.get(target.element_id, [])
)
if not rows:
continue
source_unit = str(result_units.get(metric.value, "")).strip()
series.append(
ElementHistorySeries(
element_id=target.element_id,
element_type=target.element_type,
metric=metric,
source=source,
source_unit=source_unit,
display_unit=display_unit_for_metric(metric.value),
unit_inferred=unit_inferred,
points=_points(
rows,
value_field="value",
),
)
)
return ElementHistoryResponse(series=series)