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:
2026-08-28 11:37:36 +08:00
parent b74799a39d
commit 9b095c7439
34 changed files with 859 additions and 921 deletions
+98 -2
View File
@@ -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:
+126 -30
View File
@@ -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"],
)
-66
View File
@@ -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")
]
+18 -20
View File
@@ -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})
+26 -14
View File
@@ -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()
+11 -23
View File
@@ -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}")
+124 -179
View File
@@ -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