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})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user