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})