refactor(db)!: clean up business SQL access
- make realtime replacement and analysis result writes transactional\n- consolidate SCADA repositories and remove process-global project state\n- validate SCADA batches and use indexed GIS-backed business queries\n\nBREAKING CHANGE: remove the public analysis result writer and the pipeline-health network_name query parameter.
This commit is contained in:
@@ -1,9 +1,105 @@
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg import AsyncConnection, Connection
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
|
||||
class AnalysisRepository:
|
||||
@staticmethod
|
||||
def create_run_sync(
|
||||
conn: Connection,
|
||||
*,
|
||||
name: str,
|
||||
run_type: str,
|
||||
created_by: str,
|
||||
started_at: datetime,
|
||||
status: str,
|
||||
parameters: dict[str, Any],
|
||||
run_id: UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
execution_id = run_id or uuid4()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO analysis.runs
|
||||
(run_id, name, run_type, created_by, created_at,
|
||||
started_at, status, parameters)
|
||||
VALUES (%s, %s, %s, %s, now(), %s, %s, %s)
|
||||
RETURNING run_id, name, run_type, created_by, created_at,
|
||||
started_at, status, parameters
|
||||
""",
|
||||
(
|
||||
execution_id,
|
||||
name,
|
||||
run_type,
|
||||
created_by,
|
||||
started_at,
|
||||
status,
|
||||
Jsonb(parameters),
|
||||
),
|
||||
)
|
||||
created = cur.fetchone()
|
||||
if created is None:
|
||||
raise RuntimeError("analysis run insert returned no row")
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
def update_run_sync(
|
||||
conn: Connection,
|
||||
run_id: UUID,
|
||||
*,
|
||||
status: str,
|
||||
created_by: str,
|
||||
parameters: dict[str, Any],
|
||||
) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE analysis.runs
|
||||
SET created_by = %s, status = %s, parameters = %s
|
||||
WHERE run_id = %s
|
||||
""",
|
||||
(created_by, status, Jsonb(parameters), run_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise LookupError(f"analysis run {run_id} does not exist")
|
||||
|
||||
@staticmethod
|
||||
def insert_result_sync(
|
||||
conn: Connection,
|
||||
run_id: UUID,
|
||||
*,
|
||||
result_type: str,
|
||||
payload: dict[str, Any],
|
||||
node_id: str | None = None,
|
||||
link_id: str | None = None,
|
||||
) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO analysis.results
|
||||
(run_id, result_type, node_id, link_id, payload)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""",
|
||||
(run_id, result_type, node_id, link_id, Jsonb(payload)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_run_sync(conn: Connection, run_id: UUID) -> dict[str, Any] | None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT run_id, name, run_type, created_by, created_at,
|
||||
started_at, status, parameters
|
||||
FROM analysis.runs
|
||||
WHERE run_id = %s
|
||||
""",
|
||||
(run_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
@staticmethod
|
||||
async def list_runs(conn: AsyncConnection) -> list[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
from typing import Any
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Mapping
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
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
|
||||
FROM gis.scada_devices
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScadaElementMappings:
|
||||
reservoirs: Mapping[str, str]
|
||||
tanks: Mapping[str, str]
|
||||
fixed_pumps: Mapping[str, str]
|
||||
variable_pumps: Mapping[str, str]
|
||||
pressure: Mapping[str, str]
|
||||
demand: Mapping[str, str]
|
||||
quality: Mapping[str, str]
|
||||
|
||||
|
||||
def _empty_mapping_groups() -> dict[str, dict[str, str]]:
|
||||
return {
|
||||
"reservoir_liquid_level": {},
|
||||
"tank_liquid_level": {},
|
||||
"fixed_pump": {},
|
||||
"variable_pump": {},
|
||||
"pressure": {},
|
||||
"demand": {},
|
||||
"quality": {},
|
||||
}
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
return str(value).strip() if value is not None else None
|
||||
@@ -15,6 +48,21 @@ def _optional_int(value: Any) -> int | None:
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
def _device(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": str(record["device_id"]).strip(),
|
||||
"device_type": str(record["device_type"]).strip().lower(),
|
||||
"node_id": _optional_text(record["node_id"]),
|
||||
"link_id": _optional_text(record["link_id"]),
|
||||
"api_query_id": _optional_text(record["api_query_id"]),
|
||||
"transmission_mode": record["transmission_mode"],
|
||||
"transmission_frequency": record["transmission_frequency"],
|
||||
"reliability": _optional_int(record["reliability"]),
|
||||
"x": _optional_float(record["x"]),
|
||||
"y": _optional_float(record["y"]),
|
||||
}
|
||||
|
||||
|
||||
class ScadaInfoRepository:
|
||||
"""Read SCADA metadata from the current project's business database."""
|
||||
|
||||
@@ -22,35 +70,83 @@ class ScadaInfoRepository:
|
||||
async def get_scadas(conn: AsyncConnection) -> list[dict[str, Any]]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id AS device_id,
|
||||
device_type,
|
||||
node_id,
|
||||
link_id,
|
||||
api_query_id,
|
||||
transmission_mode,
|
||||
transmission_frequency,
|
||||
reliability,
|
||||
x,
|
||||
y
|
||||
FROM gis.scada_devices
|
||||
ORDER BY id
|
||||
"""
|
||||
_SCADA_VIEW_SELECT + " ORDER BY device_id"
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"device_id": str(record["device_id"]).strip(),
|
||||
"device_type": str(record["device_type"]).strip().lower(),
|
||||
"node_id": _optional_text(record["node_id"]),
|
||||
"link_id": _optional_text(record["link_id"]),
|
||||
"api_query_id": _optional_text(record["api_query_id"]),
|
||||
"transmission_mode": record["transmission_mode"],
|
||||
"transmission_frequency": record["transmission_frequency"],
|
||||
"reliability": _optional_int(record["reliability"]),
|
||||
"x": _optional_float(record["x"]),
|
||||
"y": _optional_float(record["y"]),
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
return [_device(record) for record in records]
|
||||
|
||||
@staticmethod
|
||||
async def get_existing_device_ids(
|
||||
conn: AsyncConnection, device_ids: list[str]
|
||||
) -> set[str]:
|
||||
if not device_ids:
|
||||
return set()
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT device_id FROM asset.scada_devices WHERE device_id = ANY(%s)",
|
||||
(device_ids,),
|
||||
)
|
||||
return {str(row["device_id"]).strip() for row in await cur.fetchall()}
|
||||
|
||||
|
||||
def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"device_id": {"type": "str", "optional": False, "readonly": True},
|
||||
"device_type": {"type": "str", "optional": False, "readonly": True},
|
||||
"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},
|
||||
"transmission_mode": {"type": "str", "optional": False, "readonly": True},
|
||||
"transmission_frequency": {"type": "str", "optional": False, "readonly": True},
|
||||
"reliability": {"type": "int", "optional": False, "readonly": True},
|
||||
"x": {"type": "float", "optional": True, "readonly": True},
|
||||
"y": {"type": "float", "optional": True, "readonly": True},
|
||||
}
|
||||
|
||||
|
||||
def get_scada_info(name: str, device_id: str) -> dict[str, Any]:
|
||||
row = try_read(
|
||||
name,
|
||||
_SCADA_VIEW_SELECT + " WHERE id = %s",
|
||||
(device_id,),
|
||||
)
|
||||
return _device(row) if row else {}
|
||||
|
||||
|
||||
def get_all_scada_info(name: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_device(row)
|
||||
for row in read_all(name, _SCADA_VIEW_SELECT + " ORDER BY device_id")
|
||||
]
|
||||
|
||||
|
||||
def load_realtime_element_mappings(name: str) -> ScadaElementMappings:
|
||||
"""Load one project-local immutable SCADA-to-model mapping snapshot."""
|
||||
groups = _empty_mapping_groups()
|
||||
rows = read_all(
|
||||
name,
|
||||
"""
|
||||
SELECT device_type, COALESCE(node_id, link_id) AS element_id,
|
||||
api_query_id
|
||||
FROM asset.scada_devices
|
||||
WHERE transmission_mode = 'realtime'
|
||||
AND api_query_id IS NOT NULL
|
||||
""",
|
||||
)
|
||||
for row in rows:
|
||||
group = groups.get(str(row["device_type"]).strip().lower())
|
||||
if group is not None:
|
||||
group[str(row["element_id"]).strip()] = str(row["api_query_id"]).strip()
|
||||
immutable = {
|
||||
name: MappingProxyType(values.copy()) for name, values in groups.items()
|
||||
}
|
||||
return ScadaElementMappings(
|
||||
reservoirs=immutable["reservoir_liquid_level"],
|
||||
tanks=immutable["tank_liquid_level"],
|
||||
fixed_pumps=immutable["fixed_pump"],
|
||||
variable_pumps=immutable["variable_pump"],
|
||||
pressure=immutable["pressure"],
|
||||
demand=immutable["demand"],
|
||||
quality=immutable["quality"],
|
||||
)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from app.native.wndb.core.database import read_all, try_read
|
||||
|
||||
|
||||
def get_scada_info_schema(name: str) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
"device_id": {"type": "str", "optional": False, "readonly": True},
|
||||
"device_type": {"type": "str", "optional": False, "readonly": True},
|
||||
"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},
|
||||
"transmission_mode": {"type": "str", "optional": False, "readonly": True},
|
||||
"transmission_frequency": {"type": "str", "optional": False, "readonly": True},
|
||||
"reliability": {"type": "int", "optional": False, "readonly": True},
|
||||
"x": {"type": "float", "optional": True, "readonly": True},
|
||||
"y": {"type": "float", "optional": True, "readonly": True},
|
||||
}
|
||||
|
||||
|
||||
_SELECT = """
|
||||
SELECT device_id, device_type, node_id, link_id, api_query_id,
|
||||
transmission_mode, transmission_frequency, reliability,
|
||||
x, y
|
||||
FROM asset.scada_devices
|
||||
"""
|
||||
|
||||
_SELECT_MATERIALIZED = """
|
||||
SELECT id AS device_id, device_type, node_id, link_id, api_query_id,
|
||||
transmission_mode, transmission_frequency, reliability,
|
||||
x, y
|
||||
FROM gis.scada_devices
|
||||
"""
|
||||
|
||||
|
||||
def _device(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": str(row["device_id"]),
|
||||
"device_type": str(row["device_type"]),
|
||||
"node_id": str(row["node_id"]) if row["node_id"] is not None else None,
|
||||
"link_id": str(row["link_id"]) if row["link_id"] is not None else None,
|
||||
"api_query_id": (
|
||||
str(row["api_query_id"]) if row["api_query_id"] is not None else None
|
||||
),
|
||||
"transmission_mode": str(row["transmission_mode"]),
|
||||
"transmission_frequency": str(row["transmission_frequency"]),
|
||||
"reliability": int(row["reliability"]),
|
||||
"x": float(row["x"]) if row["x"] is not None else None,
|
||||
"y": float(row["y"]) if row["y"] is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def get_scada_info(name: str, device_id: str) -> dict[str, Any]:
|
||||
row = try_read(
|
||||
name,
|
||||
_SELECT + " WHERE device_id = %s",
|
||||
(device_id,),
|
||||
)
|
||||
return _device(row) if row else {}
|
||||
|
||||
|
||||
def get_all_scada_info(name: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
_device(row)
|
||||
for row in read_all(name, _SELECT_MATERIALIZED + " ORDER BY device_id")
|
||||
]
|
||||
@@ -1,9 +1,11 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.infra.db.postgresql.analysis import AnalysisRepository
|
||||
from app.native.wndb.core.connection import project_connection
|
||||
|
||||
|
||||
@@ -63,26 +65,22 @@ def create_sensor_placement(
|
||||
"sensor_locations": sensor_locations,
|
||||
}
|
||||
with project_connection(name) as conn, conn.transaction():
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO analysis.runs
|
||||
(run_id, name, run_type, created_by, started_at, status, parameters)
|
||||
VALUES (%s, %s, %s, %s, now(), 'completed', '{}'::jsonb)
|
||||
RETURNING run_id, name, created_by, created_at, status
|
||||
""",
|
||||
(run_id, run_name, RUN_TYPE, created_by),
|
||||
)
|
||||
created = cur.fetchone()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO analysis.results (run_id, result_type, payload)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(run_id, RESULT_TYPE, Jsonb(payload)),
|
||||
)
|
||||
if created is None:
|
||||
raise RuntimeError("监测点优化运行写入失败")
|
||||
created = AnalysisRepository.create_run_sync(
|
||||
conn,
|
||||
run_id=run_id,
|
||||
name=run_name,
|
||||
run_type=RUN_TYPE,
|
||||
created_by=created_by,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
status="completed",
|
||||
parameters={},
|
||||
)
|
||||
AnalysisRepository.insert_result_sync(
|
||||
conn,
|
||||
run_id,
|
||||
result_type=RESULT_TYPE,
|
||||
payload=payload,
|
||||
)
|
||||
return _placement_row(dict(created) | {"payload": payload})
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
|
||||
from app.infra.db.timescaledb.repositories.scada import ScadaRepository
|
||||
from app.native.wndb.model.pipes import get_pipes_by_property
|
||||
|
||||
|
||||
class CompositeQueries:
|
||||
@@ -454,20 +453,25 @@ class CompositeQueries:
|
||||
if not cleaned_rows:
|
||||
raise ValueError("SCADA 数据清洗未产生任何数据库更新")
|
||||
|
||||
updated_rows = await ScadaRepository.update_scada_field_batch(
|
||||
timescale_conn,
|
||||
cleaned_rows,
|
||||
"cleaned_value",
|
||||
)
|
||||
if updated_rows == 0:
|
||||
raise ValueError("SCADA 清洗结果未匹配任何已有监测数据")
|
||||
expected_rows = len({(row[0], row[1]) for row in cleaned_rows})
|
||||
async with timescale_conn.transaction():
|
||||
updated_rows = await ScadaRepository.update_scada_field_batch(
|
||||
timescale_conn,
|
||||
cleaned_rows,
|
||||
"cleaned_value",
|
||||
)
|
||||
if updated_rows != expected_rows:
|
||||
raise ValueError(
|
||||
"SCADA 清洗目标在写入期间发生变化,"
|
||||
f"预期更新 {expected_rows} 行,实际更新 {updated_rows} 行"
|
||||
)
|
||||
|
||||
return "success"
|
||||
|
||||
@staticmethod
|
||||
async def predict_pipeline_health(
|
||||
timescale_conn: AsyncConnection,
|
||||
network_name: str,
|
||||
postgres_conn: AsyncConnection,
|
||||
query_time: datetime,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -478,7 +482,6 @@ class CompositeQueries:
|
||||
|
||||
Args:
|
||||
timescale_conn: TimescaleDB 异步连接
|
||||
db_name: 管网数据库名称
|
||||
query_time: 查询时间
|
||||
property_conditions: 可选的管道筛选条件,如 {"diameter": 300}
|
||||
|
||||
@@ -505,12 +508,21 @@ class CompositeQueries:
|
||||
# 3. 只查询有流速数据的管道的基本信息
|
||||
valid_link_ids = list(velocity_data.keys())
|
||||
|
||||
# 批量查询这些管道的详细信息
|
||||
fields = ["id", "diameter", "node1", "node2"]
|
||||
all_links = get_pipes_by_property(network_name, fields=fields)
|
||||
# GIS 物化视图是低频更新管网的查询面;只读取本次有结果的管道。
|
||||
async with postgres_conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id, diameter, start_node_id AS node1,
|
||||
end_node_id AS node2
|
||||
FROM gis.pipes
|
||||
WHERE id = ANY(%s)
|
||||
""",
|
||||
(valid_link_ids,),
|
||||
)
|
||||
all_links = await cur.fetchall()
|
||||
|
||||
# 转换为字典以快速查找
|
||||
links_dict = {link["id"]: link for link in all_links}
|
||||
links_dict = {str(link["id"]): link for link in all_links}
|
||||
|
||||
# 获取所有需要查询的节点ID
|
||||
node_ids = set()
|
||||
|
||||
@@ -88,16 +88,15 @@ class InternalQueries:
|
||||
rows = ScadaRepository.get_scada_by_ids_time_range_sync(
|
||||
conn, device_ids, start_time, end_time
|
||||
)
|
||||
# 处理结果,返回每个 device_id 的第一个值
|
||||
result = {}
|
||||
for device_id in device_ids:
|
||||
device_rows = [
|
||||
row for row in rows if row["device_id"] == device_id
|
||||
]
|
||||
if device_rows:
|
||||
result[device_id] = device_rows[0]["monitored_value"]
|
||||
else:
|
||||
result[device_id] = None
|
||||
# Rows are ordered by device/time; retain the first sample
|
||||
# for each requested device in one pass.
|
||||
result = {device_id: None for device_id in device_ids}
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
device_id = str(row["device_id"])
|
||||
if device_id in result and device_id not in seen:
|
||||
result[device_id] = row["monitored_value"]
|
||||
seen.add(device_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"查询尝试 {attempt + 1} 失败: {e}")
|
||||
@@ -135,8 +134,6 @@ class InternalQueries:
|
||||
result.setdefault(device_id, []).append(
|
||||
{"time": row["time"].isoformat(), "value": value}
|
||||
)
|
||||
for device_id in result:
|
||||
result[device_id].sort(key=lambda item: item["time"])
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"查询尝试 {attempt + 1} 失败: {e}")
|
||||
@@ -307,16 +304,7 @@ class InternalQueries:
|
||||
def _resolve_simulation_table(element_type: str) -> tuple[str, str, set[str]]:
|
||||
normalized_type = element_type.lower()
|
||||
if normalized_type == "node":
|
||||
return "node_results", "node_id", {"actual_demand", "total_head", "pressure", "quality"}
|
||||
return "node_results", "node_id", set(RealtimeRepository.NODE_FIELDS)
|
||||
if normalized_type == "link":
|
||||
return "link_results", "link_id", {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
return "link_results", "link_id", set(RealtimeRepository.LINK_FIELDS)
|
||||
raise ValueError(f"Unsupported element_type: {element_type}")
|
||||
|
||||
@@ -6,6 +6,24 @@ from app.services.time_api import parse_utc_time
|
||||
|
||||
|
||||
class RealtimeRepository:
|
||||
LINK_FIELDS = frozenset(
|
||||
{
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
)
|
||||
NODE_FIELDS = frozenset({"actual_demand", "total_head", "pressure", "quality"})
|
||||
LINK_RESULT_COLUMNS = (
|
||||
"time, link_id, flow, friction, headloss, quality, reaction, "
|
||||
"setting, status, velocity"
|
||||
)
|
||||
NODE_RESULT_COLUMNS = "time, node_id, actual_demand, total_head, pressure, quality"
|
||||
|
||||
@staticmethod
|
||||
def _batch_time(data: List[dict]) -> datetime:
|
||||
@@ -22,6 +40,48 @@ class RealtimeRepository:
|
||||
|
||||
# --- Link Simulation ---
|
||||
|
||||
@staticmethod
|
||||
async def _copy_links(cur, data: List[dict], target_time: datetime) -> None:
|
||||
async with cur.copy(
|
||||
"COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
item.get("headloss"),
|
||||
item.get("quality"),
|
||||
item.get("reaction"),
|
||||
item.get("setting"),
|
||||
item.get("status"),
|
||||
item.get("velocity"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _copy_links_sync(cur, data: List[dict], target_time: datetime) -> None:
|
||||
with cur.copy(
|
||||
"COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
item.get("headloss"),
|
||||
item.get("quality"),
|
||||
item.get("reaction"),
|
||||
item.get("setting"),
|
||||
item.get("status"),
|
||||
item.get("velocity"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def insert_links_batch(conn: AsyncConnection, data: List[dict]):
|
||||
"""Batch insert for realtime.link_results using DELETE then COPY."""
|
||||
@@ -43,25 +103,7 @@ class RealtimeRepository:
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
item.get("headloss"),
|
||||
item.get("quality"),
|
||||
item.get("reaction"),
|
||||
item.get("setting"),
|
||||
item.get("status"),
|
||||
item.get("velocity"),
|
||||
)
|
||||
)
|
||||
await RealtimeRepository._copy_links(cur, data, target_time)
|
||||
|
||||
@staticmethod
|
||||
def insert_links_batch_sync(conn: Connection, data: List[dict]):
|
||||
@@ -84,25 +126,7 @@ class RealtimeRepository:
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY realtime.link_results (time, link_id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("flow"),
|
||||
item.get("friction"),
|
||||
item.get("headloss"),
|
||||
item.get("quality"),
|
||||
item.get("reaction"),
|
||||
item.get("setting"),
|
||||
item.get("status"),
|
||||
item.get("velocity"),
|
||||
)
|
||||
)
|
||||
RealtimeRepository._copy_links_sync(cur, data, target_time)
|
||||
|
||||
@staticmethod
|
||||
async def get_link_by_time_range(
|
||||
@@ -110,7 +134,8 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
f"SELECT {RealtimeRepository.LINK_RESULT_COLUMNS} "
|
||||
"FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
"AND link_id = %s ORDER BY time",
|
||||
(start_time, end_time, link_id),
|
||||
)
|
||||
@@ -124,46 +149,13 @@ class RealtimeRepository:
|
||||
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
f"SELECT {RealtimeRepository.LINK_RESULT_COLUMNS} "
|
||||
"FROM realtime.link_results WHERE time >= %s AND time <= %s "
|
||||
"ORDER BY time, link_id",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_link_field_by_time_range(
|
||||
conn: AsyncConnection,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
link_id: str,
|
||||
field: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Validate field name to prevent SQL injection
|
||||
valid_fields = {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.link_results WHERE time >= %s "
|
||||
"AND time <= %s AND link_id = %s ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (start_time, end_time, link_id))
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_link_fields_by_ids_time_range(
|
||||
conn: AsyncConnection,
|
||||
@@ -172,11 +164,7 @@ class RealtimeRepository:
|
||||
link_ids: list[str],
|
||||
field: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
valid_fields = {
|
||||
"flow", "friction", "headloss", "quality", "reaction",
|
||||
"setting", "status", "velocity",
|
||||
}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.LINK_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
result = {link_id: [] for link_id in link_ids}
|
||||
if not link_ids:
|
||||
@@ -202,17 +190,7 @@ class RealtimeRepository:
|
||||
field: str,
|
||||
) -> dict:
|
||||
# Validate field name to prevent SQL injection
|
||||
valid_fields = {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.LINK_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
@@ -238,17 +216,7 @@ class RealtimeRepository:
|
||||
field: str,
|
||||
value: Any,
|
||||
):
|
||||
valid_fields = {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.LINK_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
@@ -270,6 +238,40 @@ class RealtimeRepository:
|
||||
|
||||
# --- Node Simulation ---
|
||||
|
||||
@staticmethod
|
||||
async def _copy_nodes(cur, data: List[dict], target_time: datetime) -> None:
|
||||
async with cur.copy(
|
||||
"COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _copy_nodes_sync(cur, data: List[dict], target_time: datetime) -> None:
|
||||
with cur.copy(
|
||||
"COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def insert_nodes_batch(conn: AsyncConnection, data: List[dict]):
|
||||
if not data:
|
||||
@@ -290,21 +292,7 @@ class RealtimeRepository:
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
await RealtimeRepository._copy_nodes(cur, data, target_time)
|
||||
|
||||
@staticmethod
|
||||
def insert_nodes_batch_sync(conn: Connection, data: List[dict]):
|
||||
@@ -326,21 +314,7 @@ class RealtimeRepository:
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
target_time,
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
RealtimeRepository._copy_nodes_sync(cur, data, target_time)
|
||||
|
||||
@staticmethod
|
||||
async def get_node_by_time_range(
|
||||
@@ -348,7 +322,8 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
f"SELECT {RealtimeRepository.NODE_RESULT_COLUMNS} "
|
||||
"FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
"AND node_id = %s ORDER BY time",
|
||||
(start_time, end_time, node_id),
|
||||
)
|
||||
@@ -362,36 +337,13 @@ class RealtimeRepository:
|
||||
normalized_end_time = parse_utc_time(end_time, field_name="end_time")
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
f"SELECT {RealtimeRepository.NODE_RESULT_COLUMNS} "
|
||||
"FROM realtime.node_results WHERE time >= %s AND time <= %s "
|
||||
"ORDER BY time, node_id",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_node_field_by_time_range(
|
||||
conn: AsyncConnection,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
node_id: str,
|
||||
field: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.node_results WHERE time >= %s "
|
||||
"AND time <= %s AND node_id = %s ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (start_time, end_time, node_id))
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{"time": row["time"].isoformat(), "value": row[field]} for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_node_fields_by_ids_time_range(
|
||||
conn: AsyncConnection,
|
||||
@@ -400,8 +352,7 @@ class RealtimeRepository:
|
||||
node_ids: list[str],
|
||||
field: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.NODE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
result = {node_id: [] for node_id in node_ids}
|
||||
if not node_ids:
|
||||
@@ -423,8 +374,7 @@ class RealtimeRepository:
|
||||
async def get_nodes_field_by_time_range(
|
||||
conn: AsyncConnection, start_time: datetime, end_time: datetime, field: str
|
||||
) -> dict:
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.NODE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
@@ -450,8 +400,7 @@ class RealtimeRepository:
|
||||
field: str,
|
||||
value: Any,
|
||||
):
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
if field not in RealtimeRepository.NODE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
@@ -529,9 +478,8 @@ class RealtimeRepository:
|
||||
}
|
||||
)
|
||||
|
||||
# Keep node and link replacement atomic. The batch helpers use nested
|
||||
# transactions (savepoints), while this outer transaction guarantees
|
||||
# that a link write failure also rolls back the node replacement.
|
||||
# Keep node and link replacement atomic with one lock and one delete per
|
||||
# table. Copy-only helpers avoid repeating replacement SQL.
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
@@ -546,11 +494,10 @@ class RealtimeRepository:
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
if node_data:
|
||||
await RealtimeRepository.insert_nodes_batch(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
await RealtimeRepository.insert_links_batch(conn, link_data)
|
||||
if node_data:
|
||||
await RealtimeRepository._copy_nodes(cur, node_data, simulation_time)
|
||||
if link_data:
|
||||
await RealtimeRepository._copy_links(cur, link_data, simulation_time)
|
||||
|
||||
@staticmethod
|
||||
def store_realtime_simulation_result_sync(
|
||||
@@ -608,9 +555,8 @@ class RealtimeRepository:
|
||||
}
|
||||
)
|
||||
|
||||
# Keep node and link replacement atomic. The batch helpers use nested
|
||||
# transactions (savepoints), while this outer transaction guarantees
|
||||
# that a link write failure also rolls back the node replacement.
|
||||
# Keep node and link replacement atomic with one lock and one delete per
|
||||
# table. Copy-only helpers avoid repeating replacement SQL.
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
@@ -625,11 +571,10 @@ class RealtimeRepository:
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(simulation_time,),
|
||||
)
|
||||
if node_data:
|
||||
RealtimeRepository.insert_nodes_batch_sync(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
RealtimeRepository.insert_links_batch_sync(conn, link_data)
|
||||
if node_data:
|
||||
RealtimeRepository._copy_nodes_sync(cur, node_data, simulation_time)
|
||||
if link_data:
|
||||
RealtimeRepository._copy_links_sync(cur, link_data, simulation_time)
|
||||
|
||||
@staticmethod
|
||||
async def query_all_record_by_time_property(
|
||||
|
||||
@@ -6,6 +6,8 @@ from psycopg.rows import dict_row
|
||||
|
||||
|
||||
class ScadaRepository:
|
||||
VALUE_FIELDS = frozenset({"monitored_value", "cleaned_value"})
|
||||
RESULT_COLUMNS = "time, device_id, monitored_value, cleaned_value"
|
||||
|
||||
@staticmethod
|
||||
async def insert_scada_batch(conn: AsyncConnection, data: List[dict]):
|
||||
@@ -35,7 +37,8 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
|
||||
f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements "
|
||||
"WHERE device_id = ANY(%s) "
|
||||
"AND time >= %s AND time <= %s ORDER BY device_id, time",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
@@ -50,7 +53,8 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) "
|
||||
f"SELECT {ScadaRepository.RESULT_COLUMNS} FROM scada.measurements "
|
||||
"WHERE device_id = ANY(%s) "
|
||||
"AND time >= %s AND time <= %s ORDER BY device_id, time",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
@@ -85,8 +89,7 @@ class ScadaRepository:
|
||||
end_time: datetime,
|
||||
field: str,
|
||||
) -> dict:
|
||||
valid_fields = {"monitored_value", "cleaned_value"}
|
||||
if field not in valid_fields:
|
||||
if field not in ScadaRepository.VALUE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
@@ -110,8 +113,7 @@ class ScadaRepository:
|
||||
async def update_scada_field(
|
||||
conn: AsyncConnection, time: datetime, device_id: str, field: str, value: Any
|
||||
):
|
||||
valid_fields = {"monitored_value", "cleaned_value"}
|
||||
if field not in valid_fields:
|
||||
if field not in ScadaRepository.VALUE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
update_query = sql.SQL(
|
||||
@@ -133,8 +135,7 @@ class ScadaRepository:
|
||||
field: str,
|
||||
) -> int:
|
||||
"""Update existing SCADA samples in one set-based statement."""
|
||||
valid_fields = {"monitored_value", "cleaned_value"}
|
||||
if field not in valid_fields:
|
||||
if field not in ScadaRepository.VALUE_FIELDS:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user