refactor(db)!: adopt project-routed pooled databases
Reorganize WNDB by responsibility and remove legacy scheme endpoints.\n\nRoute analysis and time-series access through project pools, preserve transactional realtime replacement, and refresh GIS materialized views after writes.\n\nAdd database architecture documentation, live pooling coverage, API contract updates, and executable container verification.\n\nBREAKING CHANGE: legacy scheme APIs and flat app.native.wndb module imports are removed.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
class AnalysisRepository:
|
||||
@staticmethod
|
||||
async def list_runs(conn: AsyncConnection) -> list[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT run_id, name, run_type, created_by, created_at,
|
||||
started_at, status, parameters
|
||||
FROM analysis.runs
|
||||
ORDER BY created_at DESC, run_id
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_run(conn: AsyncConnection, run_id: UUID) -> dict | None:
|
||||
async with conn.cursor() as cur:
|
||||
await 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 await cur.fetchone()
|
||||
|
||||
@staticmethod
|
||||
async def list_results(
|
||||
conn: AsyncConnection, run_id: UUID, result_type: str | None = None
|
||||
) -> list[dict]:
|
||||
query = """
|
||||
SELECT result_id, run_id, result_type, node_id, link_id,
|
||||
payload, created_at
|
||||
FROM analysis.results
|
||||
WHERE run_id = %s
|
||||
"""
|
||||
params: tuple[UUID] | tuple[UUID, str] = (run_id,)
|
||||
if result_type is not None:
|
||||
query += " AND result_type = %s"
|
||||
params = (run_id, result_type)
|
||||
query += " ORDER BY created_at, result_id"
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
return await cur.fetchall()
|
||||
@@ -1,123 +0,0 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
import psycopg_pool
|
||||
from psycopg.rows import dict_row
|
||||
from app.infra.db.project_routing import get_project_pgconn_string
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_name=None):
|
||||
self.pool = None
|
||||
self.db_name = db_name
|
||||
self.conninfo = None
|
||||
|
||||
def init_pool(self, db_name=None):
|
||||
"""Initialize the connection pool."""
|
||||
# Use provided db_name, or the one from constructor, or default from config
|
||||
target_db_name = db_name or self.db_name
|
||||
|
||||
# Get connection string, handling default case where target_db_name might be None
|
||||
if target_db_name:
|
||||
conn_string = get_project_pgconn_string(db_name=target_db_name)
|
||||
else:
|
||||
conn_string = get_project_pgconn_string()
|
||||
self.conninfo = conn_string
|
||||
|
||||
try:
|
||||
self.pool = psycopg_pool.AsyncConnectionPool(
|
||||
conninfo=conn_string,
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
open=False, # Don't open immediately, wait for startup
|
||||
kwargs={"row_factory": dict_row}, # Return rows as dictionaries
|
||||
)
|
||||
logger.info(f"PostgreSQL connection pool initialized for database: {target_db_name or 'default'}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize postgresql connection pool: {e}")
|
||||
raise
|
||||
|
||||
async def open(self):
|
||||
if self.pool:
|
||||
await self.pool.open()
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection pool."""
|
||||
if self.pool:
|
||||
await self.pool.close()
|
||||
logger.info("PostgreSQL connection pool closed.")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(self) -> AsyncGenerator:
|
||||
"""Get a connection from the pool."""
|
||||
if not self.pool:
|
||||
raise Exception("Database pool is not initialized.")
|
||||
|
||||
async with self.pool.connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
# 默认数据库实例
|
||||
db = Database()
|
||||
|
||||
# 缓存不同数据库的实例 - 避免重复创建连接池
|
||||
_database_instances: Dict[str, Database] = {}
|
||||
|
||||
|
||||
def create_database_instance(db_name):
|
||||
"""Create a new Database instance for a specific database."""
|
||||
return Database(db_name=db_name)
|
||||
|
||||
|
||||
async def get_database_instance(db_name: Optional[str] = None) -> Database:
|
||||
"""Get or create a database instance for the specified database name."""
|
||||
if not db_name:
|
||||
return db # 返回默认数据库实例
|
||||
|
||||
expected_conninfo = get_project_pgconn_string(db_name=db_name)
|
||||
existing = _database_instances.get(db_name)
|
||||
if existing is not None and existing.conninfo != expected_conninfo:
|
||||
await existing.close()
|
||||
del _database_instances[db_name]
|
||||
|
||||
if db_name not in _database_instances:
|
||||
# 创建新的数据库实例
|
||||
instance = create_database_instance(db_name)
|
||||
instance.init_pool()
|
||||
await instance.open()
|
||||
_database_instances[db_name] = instance
|
||||
logger.info(f"Created new database instance for: {db_name}")
|
||||
|
||||
return _database_instances[db_name]
|
||||
|
||||
|
||||
async def get_db_connection():
|
||||
"""Dependency for FastAPI to get a database connection."""
|
||||
async with db.get_connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
async def get_database_connection(db_name: Optional[str] = None):
|
||||
"""
|
||||
FastAPI dependency to get database connection with optional database name.
|
||||
使用方法: conn: AsyncConnection = Depends(lambda: get_database_connection("your_db_name"))
|
||||
或在路由函数中: conn: AsyncConnection = Depends(get_database_connection)
|
||||
"""
|
||||
instance = await get_database_instance(db_name)
|
||||
async with instance.get_connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
async def cleanup_database_instances():
|
||||
"""Clean up all database instances (call this on application shutdown)."""
|
||||
for db_name, instance in _database_instances.items():
|
||||
await instance.close()
|
||||
logger.info(f"Closed database instance for: {db_name}")
|
||||
_database_instances.clear()
|
||||
|
||||
# 关闭默认数据库
|
||||
await db.close()
|
||||
logger.info("All database instances cleaned up.")
|
||||
@@ -11,6 +11,10 @@ def _optional_float(value: Any) -> float | None:
|
||||
return float(value) if value is not None else None
|
||||
|
||||
|
||||
def _optional_int(value: Any) -> int | None:
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
class ScadaInfoRepository:
|
||||
"""Read SCADA metadata from the current project's business database."""
|
||||
|
||||
@@ -19,33 +23,34 @@ class ScadaInfoRepository:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id,
|
||||
type,
|
||||
associated_element_id,
|
||||
SELECT id AS device_id,
|
||||
device_type,
|
||||
node_id,
|
||||
link_id,
|
||||
api_query_id,
|
||||
transmission_mode,
|
||||
transmission_frequency,
|
||||
reliability,
|
||||
x_coor,
|
||||
y_coor
|
||||
FROM public.scada_info
|
||||
x,
|
||||
y
|
||||
FROM gis.scada_devices
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(record["id"]).strip(),
|
||||
"type": str(record["type"]).strip().lower(),
|
||||
"associated_element_id": _optional_text(
|
||||
record["associated_element_id"]
|
||||
),
|
||||
"api_query_id": record["api_query_id"],
|
||||
"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_float(record["reliability"]),
|
||||
"x": _optional_float(record["x_coor"]),
|
||||
"y": _optional_float(record["y_coor"]),
|
||||
"reliability": _optional_int(record["reliability"]),
|
||||
"x": _optional_float(record["x"]),
|
||||
"y": _optional_float(record["y"]),
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,104 +0,0 @@
|
||||
from typing import List, Optional, Any
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
|
||||
class SchemeRepository:
|
||||
|
||||
@staticmethod
|
||||
async def get_schemes(conn: AsyncConnection) -> List[dict]:
|
||||
"""
|
||||
查询pg数据库中, scheme_list 的所有记录
|
||||
:param conn: 异步数据库连接
|
||||
:return: 包含所有记录的列表, 每条记录为一个字典
|
||||
"""
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT scheme_id, scheme_name, scheme_type, username, create_time, scheme_start_time, scheme_detail
|
||||
FROM public.scheme_list
|
||||
"""
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
scheme_list = []
|
||||
for record in records:
|
||||
scheme_list.append(
|
||||
{
|
||||
"scheme_id": record["scheme_id"],
|
||||
"scheme_name": record["scheme_name"],
|
||||
"scheme_type": record["scheme_type"],
|
||||
"username": record["username"],
|
||||
"create_time": record["create_time"],
|
||||
"scheme_start_time": record["scheme_start_time"],
|
||||
"scheme_detail": record["scheme_detail"],
|
||||
}
|
||||
)
|
||||
|
||||
return scheme_list
|
||||
|
||||
@staticmethod
|
||||
async def get_burst_locate_results(conn: AsyncConnection) -> List[dict]:
|
||||
"""
|
||||
查询pg数据库中, burst_locate_result 的所有记录
|
||||
:param conn: 异步数据库连接
|
||||
:return: 包含所有记录的列表, 每条记录为一个字典
|
||||
"""
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id, type, burst_incident, leakage, detect_time, locate_result
|
||||
FROM public.burst_locate_result
|
||||
"""
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
results.append(
|
||||
{
|
||||
"id": record["id"],
|
||||
"type": record["type"],
|
||||
"burst_incident": record["burst_incident"],
|
||||
"leakage": record["leakage"],
|
||||
"detect_time": record["detect_time"],
|
||||
"locate_result": record["locate_result"],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
async def get_burst_locate_result_by_incident(
|
||||
conn: AsyncConnection, burst_incident: str
|
||||
) -> List[dict]:
|
||||
"""
|
||||
根据 burst_incident 查询爆管定位结果
|
||||
:param conn: 异步数据库连接
|
||||
:param burst_incident: 爆管事件标识
|
||||
:return: 包含匹配记录的列表
|
||||
"""
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT id, type, burst_incident, leakage, detect_time, locate_result
|
||||
FROM public.burst_locate_result
|
||||
WHERE burst_incident = %s
|
||||
""",
|
||||
(burst_incident,),
|
||||
)
|
||||
records = await cur.fetchall()
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
results.append(
|
||||
{
|
||||
"id": record["id"],
|
||||
"type": record["type"],
|
||||
"burst_incident": record["burst_incident"],
|
||||
"leakage": record["leakage"],
|
||||
"detect_time": record["detect_time"],
|
||||
"locate_result": record["locate_result"],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,189 @@
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.native.wndb.core.connection import project_connection
|
||||
|
||||
|
||||
RUN_TYPE = "sensor_placement"
|
||||
RESULT_TYPE = "sensor_placement"
|
||||
|
||||
|
||||
def _placement_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||||
locations = [str(item) for item in payload.get("sensor_locations", [])]
|
||||
return {
|
||||
"run_id": row["run_id"],
|
||||
"name": row["name"],
|
||||
"sensor_count": len(locations),
|
||||
"min_diameter": int(payload.get("minimum_diameter", 0)),
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"sensor_locations": locations,
|
||||
}
|
||||
|
||||
|
||||
def get_all_sensor_placements(name: str) -> list[dict[str, Any]]:
|
||||
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.run_id, r.name, r.created_by, r.created_at, r.status,
|
||||
result.payload
|
||||
FROM analysis.runs AS r
|
||||
JOIN LATERAL (
|
||||
SELECT payload
|
||||
FROM analysis.results
|
||||
WHERE run_id = r.run_id AND result_type = %s
|
||||
ORDER BY created_at DESC, result_id DESC
|
||||
LIMIT 1
|
||||
) AS result ON true
|
||||
WHERE r.run_type = %s
|
||||
ORDER BY r.created_at DESC, r.run_id
|
||||
""",
|
||||
(RESULT_TYPE, RUN_TYPE),
|
||||
)
|
||||
return [_placement_row(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def create_sensor_placement(
|
||||
name: str,
|
||||
*,
|
||||
run_name: str,
|
||||
min_diameter: int,
|
||||
created_by: str,
|
||||
sensor_locations: list[str],
|
||||
) -> dict[str, Any]:
|
||||
run_id = uuid4()
|
||||
payload = {
|
||||
"sensor_number": len(sensor_locations),
|
||||
"minimum_diameter": min_diameter,
|
||||
"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("监测点优化运行写入失败")
|
||||
return _placement_row(dict(created) | {"payload": payload})
|
||||
|
||||
|
||||
def get_sensor_placement(name: str, run_id: UUID) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.run_id, r.name, r.created_by, r.created_at, r.status,
|
||||
result.payload
|
||||
FROM analysis.runs AS r
|
||||
JOIN LATERAL (
|
||||
SELECT payload
|
||||
FROM analysis.results
|
||||
WHERE run_id = r.run_id AND result_type = %s
|
||||
ORDER BY created_at DESC, result_id DESC
|
||||
LIMIT 1
|
||||
) AS result ON true
|
||||
WHERE r.run_id = %s AND r.run_type = %s
|
||||
""",
|
||||
(RESULT_TYPE, run_id, RUN_TYPE),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return _placement_row(row) if row else None
|
||||
|
||||
|
||||
def get_sensor_placement_nodes(
|
||||
name: str,
|
||||
node_ids: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not node_ids:
|
||||
return []
|
||||
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
WITH incident_pipe_diameters AS (
|
||||
SELECT node_id, MAX(diameter) AS max_pipe_diameter
|
||||
FROM (
|
||||
SELECT l.start_node_id AS node_id, p.diameter
|
||||
FROM network.pipes AS p
|
||||
JOIN network.links AS l ON l.id = p.link_id
|
||||
WHERE l.start_node_id = ANY(%s)
|
||||
UNION ALL
|
||||
SELECT l.end_node_id AS node_id, p.diameter
|
||||
FROM network.pipes AS p
|
||||
JOIN network.links AS l ON l.id = p.link_id
|
||||
WHERE l.end_node_id = ANY(%s)
|
||||
) AS incident_pipes
|
||||
GROUP BY node_id
|
||||
)
|
||||
SELECT j.node_id,
|
||||
ipd.max_pipe_diameter,
|
||||
j.elevation,
|
||||
ST_X(g.geom) AS project_x,
|
||||
ST_Y(g.geom) AS project_y,
|
||||
ST_X(ST_Transform(g.geom, 3857)) AS map_x,
|
||||
ST_Y(ST_Transform(g.geom, 3857)) AS map_y
|
||||
FROM network.junctions AS j
|
||||
JOIN gis.node_geometries AS g ON g.node_id = j.node_id
|
||||
LEFT JOIN incident_pipe_diameters AS ipd ON ipd.node_id = j.node_id
|
||||
WHERE j.node_id = ANY(%s)
|
||||
ORDER BY j.node_id
|
||||
""",
|
||||
(node_ids, node_ids, node_ids),
|
||||
)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def update_sensor_placement(
|
||||
name: str,
|
||||
run_id: UUID,
|
||||
*,
|
||||
expected_sensor_locations: list[str],
|
||||
sensor_locations: list[str],
|
||||
) -> dict[str, Any] | None:
|
||||
with project_connection(name) as conn, conn.transaction():
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT result_id, payload
|
||||
FROM analysis.results
|
||||
WHERE run_id = %s AND result_type = %s
|
||||
ORDER BY created_at DESC, result_id DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
""",
|
||||
(run_id, RESULT_TYPE),
|
||||
)
|
||||
result = cur.fetchone()
|
||||
if result is None:
|
||||
return None
|
||||
payload = result["payload"] if isinstance(result["payload"], dict) else {}
|
||||
current = [str(item) for item in payload.get("sensor_locations", [])]
|
||||
if current != expected_sensor_locations:
|
||||
return None
|
||||
payload = {
|
||||
**payload,
|
||||
"sensor_number": len(sensor_locations),
|
||||
"sensor_locations": sensor_locations,
|
||||
}
|
||||
cur.execute(
|
||||
"UPDATE analysis.results SET payload = %s WHERE result_id = %s",
|
||||
(Jsonb(payload), result["result_id"]),
|
||||
)
|
||||
return get_sensor_placement(name, run_id)
|
||||
@@ -1,2 +1 @@
|
||||
from .database import *
|
||||
from .composite_queries import CompositeQueries
|
||||
from .composite_queries import CompositeQueries
|
||||
|
||||
@@ -5,15 +5,16 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from psycopg import AsyncConnection
|
||||
from uuid import UUID
|
||||
|
||||
import app.native.wndb as wndb
|
||||
from app.algorithms.cleaning.flow import clean_flow_data_df_kf
|
||||
from app.algorithms.cleaning.pressure import clean_pressure_data_df_km
|
||||
from app.algorithms.health.analyzer import PipelineHealthAnalyzer
|
||||
from app.infra.db.postgresql.scada import ScadaInfoRepository
|
||||
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
|
||||
from app.infra.db.timescaledb.repositories.scheme import SchemeRepository
|
||||
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:
|
||||
@@ -26,7 +27,7 @@ class CompositeQueries:
|
||||
postgres_conn: AsyncConnection,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
scadas = await ScadaInfoRepository.get_scadas(postgres_conn)
|
||||
return {scada["id"]: scada for scada in scadas}
|
||||
return {scada["device_id"]: scada for scada in scadas}
|
||||
|
||||
@staticmethod
|
||||
async def get_scada_associated_realtime_simulation_data(
|
||||
@@ -63,8 +64,12 @@ class CompositeQueries:
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
element_id = target_scada["associated_element_id"]
|
||||
scada_type = target_scada["type"]
|
||||
scada_type = target_scada["device_type"]
|
||||
element_id = (
|
||||
target_scada["link_id"]
|
||||
if scada_type in {"pipe_flow", "flow"}
|
||||
else target_scada["node_id"]
|
||||
)
|
||||
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
@@ -85,17 +90,16 @@ class CompositeQueries:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_scada_associated_scheme_simulation_data(
|
||||
async def get_scada_associated_analysis_simulation_data(
|
||||
timescale_conn: AsyncConnection,
|
||||
postgres_conn: AsyncConnection,
|
||||
device_ids: List[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
run_id: UUID,
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取 SCADA 关联的 link/node scheme 模拟值
|
||||
获取 SCADA 关联的 link/node 分析模拟值
|
||||
|
||||
根据传入的 SCADA device_ids,找到关联的 link/node,
|
||||
并根据对应的 type,查询对应的模拟数据
|
||||
@@ -121,30 +125,22 @@ class CompositeQueries:
|
||||
if not target_scada:
|
||||
raise ValueError(f"SCADA device {device_id} not found")
|
||||
|
||||
element_id = target_scada["associated_element_id"]
|
||||
scada_type = target_scada["type"]
|
||||
scada_type = target_scada["device_type"]
|
||||
element_id = (
|
||||
target_scada["link_id"]
|
||||
if scada_type in {"pipe_flow", "flow"}
|
||||
else target_scada["node_id"]
|
||||
)
|
||||
|
||||
if scada_type == "pipe_flow":
|
||||
# 查询 link 模拟数据
|
||||
res = await SchemeRepository.get_link_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
start_time,
|
||||
end_time,
|
||||
element_id,
|
||||
"flow",
|
||||
res = await AnalysisResultsRepository.get_link_series(
|
||||
timescale_conn, run_id, element_id, start_time, end_time, "flow"
|
||||
)
|
||||
elif scada_type == "pressure":
|
||||
# 查询 node 模拟数据
|
||||
res = await SchemeRepository.get_node_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
start_time,
|
||||
end_time,
|
||||
element_id,
|
||||
"pressure",
|
||||
res = await AnalysisResultsRepository.get_node_series(
|
||||
timescale_conn, run_id, element_id, start_time, end_time, "pressure"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown SCADA type: {scada_type}")
|
||||
@@ -201,16 +197,15 @@ class CompositeQueries:
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_scheme_simulation_data(
|
||||
async def get_analysis_simulation_data(
|
||||
timescale_conn: AsyncConnection,
|
||||
feature_infos: List[Tuple[str, str]],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
run_id: UUID,
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
获取 link/node scheme 模拟值
|
||||
获取 link/node 分析模拟值
|
||||
|
||||
根据传入的 feature_infos,找到关联的 link/node,
|
||||
并根据对应的 type,查询对应的模拟数据
|
||||
@@ -220,8 +215,7 @@ class CompositeQueries:
|
||||
feature_infos: 传入的 feature 信息列表,包含 (element_id, type)
|
||||
start_time: 开始时间
|
||||
end_time: 结束时间
|
||||
scheme_type: 工况类型
|
||||
scheme_name: 工况名称
|
||||
run_id: 分析运行 ID
|
||||
|
||||
Returns:
|
||||
模拟数据字典,以 feature_id 为键,值为数据列表,每个数据包含 time, value 和 feature_id
|
||||
@@ -233,25 +227,13 @@ class CompositeQueries:
|
||||
for feature_id, feature_type in feature_infos:
|
||||
if feature_type.lower() == "pipe":
|
||||
# 查询 link 模拟数据
|
||||
res = await SchemeRepository.get_link_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
start_time,
|
||||
end_time,
|
||||
feature_id,
|
||||
"flow",
|
||||
res = await AnalysisResultsRepository.get_link_series(
|
||||
timescale_conn, run_id, feature_id, start_time, end_time, "flow"
|
||||
)
|
||||
elif feature_type.lower() == "junction":
|
||||
# 查询 node 模拟数据
|
||||
res = await SchemeRepository.get_node_field_by_scheme_and_time_range(
|
||||
timescale_conn,
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
start_time,
|
||||
end_time,
|
||||
feature_id,
|
||||
"pressure",
|
||||
res = await AnalysisResultsRepository.get_node_series(
|
||||
timescale_conn, run_id, feature_id, start_time, end_time, "pressure"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {feature_type}")
|
||||
@@ -296,7 +278,7 @@ class CompositeQueries:
|
||||
(
|
||||
scada
|
||||
for scada in scada_by_id.values()
|
||||
if scada["associated_element_id"] == element_id
|
||||
if (scada.get("node_id") or scada.get("link_id")) == element_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -304,7 +286,7 @@ class CompositeQueries:
|
||||
if not associated_scada:
|
||||
return None
|
||||
|
||||
device_id = associated_scada["id"]
|
||||
device_id = associated_scada["device_id"]
|
||||
|
||||
data_field = "cleaned_value" if use_cleaned else "monitored_value"
|
||||
|
||||
@@ -358,7 +340,7 @@ class CompositeQueries:
|
||||
unsupported_ids = [
|
||||
device_id
|
||||
for device_id in device_ids
|
||||
if scada_by_id[device_id]["type"] not in supported_types
|
||||
if scada_by_id[device_id]["device_type"] not in supported_types
|
||||
]
|
||||
if unsupported_ids:
|
||||
raise ValueError(
|
||||
@@ -368,7 +350,7 @@ class CompositeQueries:
|
||||
device_ids = [
|
||||
device_id
|
||||
for device_id, info in scada_by_id.items()
|
||||
if info["type"] in supported_types
|
||||
if info["device_type"] in supported_types
|
||||
]
|
||||
|
||||
if not device_ids:
|
||||
@@ -409,12 +391,12 @@ class CompositeQueries:
|
||||
pressure_ids = [
|
||||
device_id
|
||||
for device_id in df.columns
|
||||
if scada_by_id[device_id]["type"] == "pressure"
|
||||
if scada_by_id[device_id]["device_type"] == "pressure"
|
||||
]
|
||||
flow_ids = [
|
||||
device_id
|
||||
for device_id in df.columns
|
||||
if scada_by_id[device_id]["type"] in {"pipe_flow", "flow"}
|
||||
if scada_by_id[device_id]["device_type"] in {"pipe_flow", "flow"}
|
||||
]
|
||||
|
||||
updated_rows = 0
|
||||
@@ -497,7 +479,7 @@ class CompositeQueries:
|
||||
|
||||
# 批量查询这些管道的详细信息
|
||||
fields = ["id", "diameter", "node1", "node2"]
|
||||
all_links = wndb.get_pipes_by_property(network_name, fields=fields)
|
||||
all_links = get_pipes_by_property(network_name, fields=fields)
|
||||
|
||||
# 转换为字典以快速查找
|
||||
links_dict = {link["id"]: link for link in all_links}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
import psycopg_pool
|
||||
from psycopg.rows import dict_row
|
||||
from app.infra.db.project_routing import get_project_timescale_pgconn_string
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_name=None):
|
||||
self.pool = None
|
||||
self.db_name = db_name
|
||||
self.conninfo = None
|
||||
|
||||
def init_pool(self, db_name=None):
|
||||
"""Initialize the connection pool."""
|
||||
# Use provided db_name, or the one from constructor, or default from config
|
||||
target_db_name = db_name or self.db_name
|
||||
|
||||
# Get connection string, handling default case where target_db_name might be None
|
||||
if target_db_name:
|
||||
conn_string = get_project_timescale_pgconn_string(db_name=target_db_name)
|
||||
else:
|
||||
conn_string = get_project_timescale_pgconn_string()
|
||||
self.conninfo = conn_string
|
||||
|
||||
try:
|
||||
self.pool = psycopg_pool.AsyncConnectionPool(
|
||||
conninfo=conn_string,
|
||||
min_size=5,
|
||||
max_size=20,
|
||||
open=False, # Don't open immediately, wait for startup
|
||||
kwargs={"row_factory": dict_row}, # Return rows as dictionaries
|
||||
)
|
||||
logger.info(
|
||||
f"TimescaleDB connection pool initialized for database: {target_db_name or 'default'}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize TimescaleDB connection pool: {e}")
|
||||
raise
|
||||
|
||||
async def open(self):
|
||||
if self.pool:
|
||||
await self.pool.open()
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection pool."""
|
||||
if self.pool:
|
||||
await self.pool.close()
|
||||
logger.info("TimescaleDB connection pool closed.")
|
||||
|
||||
def get_pgconn_string(self, db_name=None):
|
||||
"""Get the TimescaleDB connection string."""
|
||||
target_db_name = db_name or self.db_name
|
||||
if target_db_name:
|
||||
return get_project_timescale_pgconn_string(db_name=target_db_name)
|
||||
return get_project_timescale_pgconn_string()
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(self) -> AsyncGenerator:
|
||||
"""Get a connection from the pool."""
|
||||
if not self.pool:
|
||||
raise Exception("Database pool is not initialized.")
|
||||
|
||||
async with self.pool.connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
# 默认数据库实例
|
||||
db = Database()
|
||||
|
||||
# 缓存不同数据库的实例 - 避免重复创建连接池
|
||||
_database_instances: Dict[str, Database] = {}
|
||||
|
||||
|
||||
def create_database_instance(db_name):
|
||||
"""Create a new Database instance for a specific database."""
|
||||
return Database(db_name=db_name)
|
||||
|
||||
|
||||
async def get_database_instance(db_name: Optional[str] = None) -> Database:
|
||||
"""Get or create a database instance for the specified database name."""
|
||||
if not db_name:
|
||||
return db # 返回默认数据库实例
|
||||
|
||||
expected_conninfo = get_project_timescale_pgconn_string(db_name=db_name)
|
||||
existing = _database_instances.get(db_name)
|
||||
if existing is not None and existing.conninfo != expected_conninfo:
|
||||
await existing.close()
|
||||
del _database_instances[db_name]
|
||||
|
||||
if db_name not in _database_instances:
|
||||
# 创建新的数据库实例
|
||||
instance = create_database_instance(db_name)
|
||||
instance.init_pool()
|
||||
await instance.open()
|
||||
_database_instances[db_name] = instance
|
||||
logger.info(f"Created new database instance for: {db_name}")
|
||||
|
||||
return _database_instances[db_name]
|
||||
|
||||
|
||||
async def get_db_connection():
|
||||
"""Dependency for FastAPI to get a database connection."""
|
||||
async with db.get_connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
async def get_database_connection(db_name: Optional[str] = None):
|
||||
"""
|
||||
FastAPI dependency to get database connection with optional database name.
|
||||
使用方法: conn: AsyncConnection = Depends(lambda: get_database_connection("your_db_name"))
|
||||
或在路由函数中: conn: AsyncConnection = Depends(get_database_connection)
|
||||
"""
|
||||
instance = await get_database_instance(db_name)
|
||||
async with instance.get_connection() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
async def cleanup_database_instances():
|
||||
"""Clean up all database instances (call this on application shutdown)."""
|
||||
for db_name, instance in _database_instances.items():
|
||||
await instance.close()
|
||||
logger.info(f"Closed database instance for: {db_name}")
|
||||
_database_instances.clear()
|
||||
|
||||
# 关闭默认数据库
|
||||
await db.close()
|
||||
logger.info("All database instances cleaned up.")
|
||||
@@ -2,12 +2,12 @@ from typing import List
|
||||
|
||||
from fastapi.logger import logger
|
||||
from datetime import datetime, timedelta
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.rows import dict_row
|
||||
import time
|
||||
from app.infra.db.project_routing import get_project_timescale_pgconn_string
|
||||
from app.infra.db.timescaledb.repositories.scheme import SchemeRepository
|
||||
from app.infra.db.timescaledb.sync_pool import timescale_connection
|
||||
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
|
||||
from app.services.time_api import parse_utc_time
|
||||
@@ -25,12 +25,7 @@ class InternalStorage:
|
||||
"""存储实时模拟结果"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
with timescale_connection(db_name) as conn:
|
||||
RealtimeRepository.store_realtime_simulation_result_sync(
|
||||
conn, node_result_list, link_result_list, result_start_time
|
||||
)
|
||||
@@ -43,9 +38,8 @@ class InternalStorage:
|
||||
raise # 达到最大重试次数后抛出异常
|
||||
|
||||
@staticmethod
|
||||
def store_scheme_simulation(
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
def store_analysis_simulation(
|
||||
run_id,
|
||||
node_result_list: List[dict],
|
||||
link_result_list: List[dict],
|
||||
result_start_time: str,
|
||||
@@ -54,24 +48,16 @@ class InternalStorage:
|
||||
db_name: str = None,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
"""存储方案模拟结果"""
|
||||
"""Store immutable simulation results for one analysis run."""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
SchemeRepository.store_scheme_simulation_result_sync(
|
||||
conn,
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
node_result_list,
|
||||
link_result_list,
|
||||
result_start_time,
|
||||
num_periods,
|
||||
result_timestep_seconds,
|
||||
with timescale_connection(db_name) as conn:
|
||||
node_rows, link_rows = AnalysisResultsRepository.prepare_simulation_rows(
|
||||
node_result_list, link_result_list, result_start_time,
|
||||
num_periods, result_timestep_seconds or 3600,
|
||||
)
|
||||
AnalysisResultsRepository.store_results_sync(
|
||||
conn, run_id, node_rows, link_rows
|
||||
)
|
||||
break # 成功
|
||||
except Exception as e:
|
||||
@@ -98,12 +84,7 @@ class InternalQueries:
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
with timescale_connection(db_name) as conn:
|
||||
rows = ScadaRepository.get_scada_by_ids_time_range_sync(
|
||||
conn, device_ids, start_time, end_time
|
||||
)
|
||||
@@ -139,12 +120,7 @@ class InternalQueries:
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
with timescale_connection(db_name) as conn:
|
||||
rows = ScadaRepository.get_scada_by_ids_time_range_sync(
|
||||
conn, device_ids, start_dt, end_dt
|
||||
)
|
||||
@@ -184,12 +160,7 @@ class InternalQueries:
|
||||
)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
with timescale_connection(db_name) as conn:
|
||||
return ScadaRepository.get_latest_scada_time_sync(
|
||||
conn,
|
||||
device_ids,
|
||||
@@ -224,20 +195,19 @@ class InternalQueries:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def query_scheme_simulation_by_ids_timerange(
|
||||
def query_analysis_simulation_by_ids_timerange(
|
||||
element_ids: List[str],
|
||||
start_time: str | datetime,
|
||||
end_time: str | datetime,
|
||||
element_type: str,
|
||||
field: str,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
run_id,
|
||||
db_name: str = None,
|
||||
max_retries: int = 3,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""查询方案模拟结果,返回 {id: [{time, value}, ...]}。"""
|
||||
"""Query one analysis run, returning {id: [{time, value}, ...]}."""
|
||||
return InternalQueries._query_simulation_by_ids_timerange(
|
||||
schema_name="scheme",
|
||||
schema_name="analysis",
|
||||
element_ids=element_ids,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
@@ -245,8 +215,7 @@ class InternalQueries:
|
||||
field=field,
|
||||
db_name=db_name,
|
||||
max_retries=max_retries,
|
||||
scheme_type=scheme_type,
|
||||
scheme_name=scheme_name,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -260,8 +229,7 @@ class InternalQueries:
|
||||
field: str,
|
||||
db_name: str = None,
|
||||
max_retries: int = 3,
|
||||
scheme_type: str | None = None,
|
||||
scheme_name: str | None = None,
|
||||
run_id=None,
|
||||
) -> dict[str, list[dict]]:
|
||||
normalized_element_ids = list(
|
||||
dict.fromkeys(
|
||||
@@ -275,51 +243,44 @@ class InternalQueries:
|
||||
|
||||
start_dt = parse_utc_time(start_time, field_name="start_time")
|
||||
end_dt = parse_utc_time(end_time, field_name="end_time")
|
||||
table_name, valid_fields = InternalQueries._resolve_simulation_table(element_type)
|
||||
table_name, id_column, valid_fields = InternalQueries._resolve_simulation_table(element_type)
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field for {element_type}: {field}")
|
||||
if schema_name not in {"realtime", "scheme"}:
|
||||
if schema_name not in {"realtime", "analysis"}:
|
||||
raise ValueError(f"Unsupported schema_name: {schema_name}")
|
||||
if schema_name == "scheme" and (not scheme_type or not scheme_name):
|
||||
raise ValueError("scheme 查询必须提供 scheme_type 和 scheme_name。")
|
||||
if schema_name == "analysis" and run_id is None:
|
||||
raise ValueError("analysis query requires run_id")
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
conn_string = (
|
||||
get_project_timescale_pgconn_string(db_name=db_name)
|
||||
if db_name
|
||||
else get_project_timescale_pgconn_string()
|
||||
)
|
||||
with psycopg.Connection.connect(conn_string) as conn:
|
||||
with timescale_connection(db_name) as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
if schema_name == "scheme":
|
||||
if schema_name == "analysis":
|
||||
query = sql.SQL(
|
||||
"SELECT btrim(id::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE scheme_type = %s AND scheme_name = %s "
|
||||
"AND time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)"
|
||||
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE run_id = %s AND time >= %s AND time <= %s "
|
||||
"AND btrim({}::text) = ANY(%s)"
|
||||
).format(
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(field),
|
||||
sql.Identifier(schema_name),
|
||||
sql.Identifier(table_name),
|
||||
sql.Identifier(id_column),
|
||||
)
|
||||
cur.execute(
|
||||
query,
|
||||
(
|
||||
scheme_type,
|
||||
scheme_name,
|
||||
start_dt,
|
||||
end_dt,
|
||||
normalized_element_ids,
|
||||
),
|
||||
(run_id, start_dt, end_dt, normalized_element_ids),
|
||||
)
|
||||
else:
|
||||
query = sql.SQL(
|
||||
"SELECT btrim(id::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE time >= %s AND time <= %s AND btrim(id::text) = ANY(%s)"
|
||||
"SELECT btrim({}::text) AS id, time, {} FROM {}.{} "
|
||||
"WHERE time >= %s AND time <= %s AND btrim({}::text) = ANY(%s)"
|
||||
).format(
|
||||
sql.Identifier(id_column),
|
||||
sql.Identifier(field),
|
||||
sql.Identifier(schema_name),
|
||||
sql.Identifier(table_name),
|
||||
sql.Identifier(id_column),
|
||||
)
|
||||
cur.execute(query, (start_dt, end_dt, normalized_element_ids))
|
||||
rows = cur.fetchall()
|
||||
@@ -342,12 +303,12 @@ class InternalQueries:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _resolve_simulation_table(element_type: str) -> tuple[str, set[str]]:
|
||||
def _resolve_simulation_table(element_type: str) -> tuple[str, str, set[str]]:
|
||||
normalized_type = element_type.lower()
|
||||
if normalized_type == "node":
|
||||
return "node_simulation", {"actual_demand", "total_head", "pressure", "quality"}
|
||||
return "node_results", "node_id", {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if normalized_type == "link":
|
||||
return "link_simulation", {
|
||||
return "link_results", "link_id", {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection, Connection, sql
|
||||
|
||||
from app.services.time_api import parse_utc_time
|
||||
|
||||
|
||||
class AnalysisResultsRepository:
|
||||
NODE_FIELDS = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
LINK_FIELDS = {
|
||||
"flow",
|
||||
"friction",
|
||||
"headloss",
|
||||
"quality",
|
||||
"reaction",
|
||||
"setting",
|
||||
"status",
|
||||
"velocity",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def prepare_simulation_rows(
|
||||
node_results: list[dict[str, Any]],
|
||||
link_results: list[dict[str, Any]],
|
||||
result_start_time: str,
|
||||
num_periods: int,
|
||||
result_timestep_seconds: int,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
start_time = parse_utc_time(
|
||||
result_start_time, field_name="result_start_time"
|
||||
)
|
||||
timestep = timedelta(seconds=result_timestep_seconds)
|
||||
node_rows: list[dict[str, Any]] = []
|
||||
for node_result in node_results:
|
||||
for period_index, values in enumerate(
|
||||
node_result.get("result", [])[:num_periods]
|
||||
):
|
||||
node_rows.append(
|
||||
{
|
||||
"time": start_time + timestep * period_index,
|
||||
"node_id": node_result["node"],
|
||||
"actual_demand": values.get("demand"),
|
||||
"total_head": values.get("head"),
|
||||
"pressure": values.get("pressure"),
|
||||
"quality": values.get("quality"),
|
||||
}
|
||||
)
|
||||
link_rows: list[dict[str, Any]] = []
|
||||
for link_result in link_results:
|
||||
for period_index, values in enumerate(
|
||||
link_result.get("result", [])[:num_periods]
|
||||
):
|
||||
link_rows.append(
|
||||
{
|
||||
"time": start_time + timestep * period_index,
|
||||
"link_id": link_result["link"],
|
||||
**{field: values.get(field) for field in AnalysisResultsRepository.LINK_FIELDS},
|
||||
}
|
||||
)
|
||||
return node_rows, link_rows
|
||||
|
||||
@staticmethod
|
||||
async def store_results(
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
node_rows: list[dict[str, Any]],
|
||||
link_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
await AnalysisResultsRepository._lock_run(cur, run_id)
|
||||
await AnalysisResultsRepository._assert_run_is_empty(cur, run_id)
|
||||
if node_rows:
|
||||
async with cur.copy(
|
||||
"COPY analysis.node_results "
|
||||
"(time, run_id, node_id, actual_demand, total_head, pressure, quality) "
|
||||
"FROM STDIN"
|
||||
) as copy:
|
||||
for row in node_rows:
|
||||
await copy.write_row(
|
||||
(
|
||||
row["time"],
|
||||
run_id,
|
||||
row["node_id"],
|
||||
row.get("actual_demand"),
|
||||
row.get("total_head"),
|
||||
row.get("pressure"),
|
||||
row.get("quality"),
|
||||
)
|
||||
)
|
||||
if link_rows:
|
||||
async with cur.copy(
|
||||
"COPY analysis.link_results "
|
||||
"(time, run_id, link_id, flow, friction, headloss, quality, "
|
||||
"reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for row in link_rows:
|
||||
await copy.write_row(
|
||||
(
|
||||
row["time"],
|
||||
run_id,
|
||||
row["link_id"],
|
||||
row.get("flow"),
|
||||
row.get("friction"),
|
||||
row.get("headloss"),
|
||||
row.get("quality"),
|
||||
row.get("reaction"),
|
||||
row.get("setting"),
|
||||
row.get("status"),
|
||||
row.get("velocity"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _assert_run_is_empty(cur, run_id: UUID) -> None:
|
||||
await cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM analysis.node_results WHERE run_id = %s
|
||||
UNION ALL
|
||||
SELECT 1 FROM analysis.link_results WHERE run_id = %s
|
||||
) AS exists
|
||||
""",
|
||||
(run_id, run_id),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row and row["exists"]:
|
||||
raise ValueError(f"analysis results already exist for run {run_id}")
|
||||
|
||||
@staticmethod
|
||||
async def _lock_run(cur, run_id: UUID) -> None:
|
||||
await cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))",
|
||||
(run_id,),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_node_series(
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
node_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
field: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if field not in AnalysisResultsRepository.NODE_FIELDS:
|
||||
raise ValueError(f"invalid node result field: {field}")
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} AS value FROM analysis.node_results "
|
||||
"WHERE run_id = %s AND node_id = %s AND time BETWEEN %s AND %s "
|
||||
"ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (run_id, node_id, start_time, end_time))
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_link_series(
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
link_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
field: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
if field not in AnalysisResultsRepository.LINK_FIELDS:
|
||||
raise ValueError(f"invalid link result field: {field}")
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} AS value FROM analysis.link_results "
|
||||
"WHERE run_id = %s AND link_id = %s AND time BETWEEN %s AND %s "
|
||||
"ORDER BY time"
|
||||
).format(sql.Identifier(field))
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (run_id, link_id, start_time, end_time))
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_values_at_time(
|
||||
conn: AsyncConnection,
|
||||
run_id: UUID,
|
||||
element_type: str,
|
||||
result_time: datetime,
|
||||
field: str,
|
||||
) -> dict[str, Any]:
|
||||
if element_type == "node":
|
||||
table, id_column, fields = (
|
||||
"node_results",
|
||||
"node_id",
|
||||
AnalysisResultsRepository.NODE_FIELDS,
|
||||
)
|
||||
elif element_type == "link":
|
||||
table, id_column, fields = (
|
||||
"link_results",
|
||||
"link_id",
|
||||
AnalysisResultsRepository.LINK_FIELDS,
|
||||
)
|
||||
else:
|
||||
raise ValueError("element_type must be node or link")
|
||||
if field not in fields:
|
||||
raise ValueError(f"invalid {element_type} result field: {field}")
|
||||
query = sql.SQL(
|
||||
"SELECT {id_column}, {field} AS value FROM analysis.{table} "
|
||||
"WHERE run_id = %s AND time = %s ORDER BY {id_column}"
|
||||
).format(
|
||||
id_column=sql.Identifier(id_column),
|
||||
field=sql.Identifier(field),
|
||||
table=sql.Identifier(table),
|
||||
)
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (run_id, result_time))
|
||||
return {row[id_column]: row["value"] for row in await cur.fetchall()}
|
||||
|
||||
@staticmethod
|
||||
def store_results_sync(
|
||||
conn: Connection,
|
||||
run_id: UUID,
|
||||
node_rows: list[dict[str, Any]],
|
||||
link_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
with conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 0))",
|
||||
(run_id,),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM analysis.node_results WHERE run_id = %s
|
||||
UNION ALL
|
||||
SELECT 1 FROM analysis.link_results WHERE run_id = %s
|
||||
) AS exists
|
||||
""",
|
||||
(run_id, run_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row["exists"]:
|
||||
raise ValueError(f"analysis results already exist for run {run_id}")
|
||||
if node_rows:
|
||||
with cur.copy(
|
||||
"COPY analysis.node_results "
|
||||
"(time, run_id, node_id, actual_demand, total_head, pressure, quality) "
|
||||
"FROM STDIN"
|
||||
) as copy:
|
||||
for item in node_rows:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"], run_id, item["node_id"],
|
||||
item.get("actual_demand"), item.get("total_head"),
|
||||
item.get("pressure"), item.get("quality"),
|
||||
)
|
||||
)
|
||||
if link_rows:
|
||||
with cur.copy(
|
||||
"COPY analysis.link_results "
|
||||
"(time, run_id, link_id, flow, friction, headloss, quality, "
|
||||
"reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in link_rows:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"], run_id, item["link_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"),
|
||||
)
|
||||
)
|
||||
@@ -11,7 +11,7 @@ class RealtimeRepository:
|
||||
|
||||
@staticmethod
|
||||
async def insert_links_batch(conn: AsyncConnection, data: List[dict]):
|
||||
"""Batch insert for realtime.link_simulation using DELETE then COPY for performance."""
|
||||
"""Batch insert for realtime.link_results using DELETE then COPY."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
@@ -21,15 +21,19 @@ class RealtimeRepository:
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(target_time,),
|
||||
)
|
||||
# 1. 先删除该时间点的旧数据
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.link_simulation WHERE time = %s",
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY realtime.link_simulation (time, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
"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(
|
||||
@@ -49,7 +53,7 @@ class RealtimeRepository:
|
||||
|
||||
@staticmethod
|
||||
def insert_links_batch_sync(conn: Connection, data: List[dict]):
|
||||
"""Batch insert for realtime.link_simulation using DELETE then COPY for performance (sync version)."""
|
||||
"""Synchronous batch insert for realtime.link_results."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
@@ -59,15 +63,19 @@ class RealtimeRepository:
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(target_time,),
|
||||
)
|
||||
# 1. 先删除该时间点的旧数据
|
||||
cur.execute(
|
||||
"DELETE FROM realtime.link_simulation WHERE time = %s",
|
||||
"DELETE FROM realtime.link_results WHERE time = %s",
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY realtime.link_simulation (time, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
"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(
|
||||
@@ -91,7 +99,7 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.link_simulation WHERE time >= %s AND time <= %s AND id = %s",
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s",
|
||||
(start_time, end_time, link_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -104,7 +112,7 @@ 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_simulation WHERE time >= %s AND time <= %s",
|
||||
"SELECT * FROM realtime.link_results WHERE time >= %s AND time <= %s",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -132,7 +140,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.link_simulation WHERE time >= %s AND time <= %s AND id = %s"
|
||||
"SELECT time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s AND link_id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -164,7 +172,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM realtime.link_simulation WHERE time >= %s AND time <= %s"
|
||||
"SELECT link_id, time, {} FROM realtime.link_results WHERE time >= %s AND time <= %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -172,7 +180,7 @@ class RealtimeRepository:
|
||||
rows = await cur.fetchall()
|
||||
result = defaultdict(list)
|
||||
for row in rows:
|
||||
result[row["id"]].append(
|
||||
result[row["link_id"]].append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return dict(result)
|
||||
@@ -199,7 +207,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"UPDATE realtime.link_simulation SET {} = %s WHERE time = %s AND id = %s"
|
||||
"UPDATE realtime.link_results SET {} = %s WHERE time = %s AND link_id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -211,7 +219,7 @@ class RealtimeRepository:
|
||||
):
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.link_simulation WHERE time >= %s AND time <= %s",
|
||||
"DELETE FROM realtime.link_results WHERE time >= %s AND time <= %s",
|
||||
(start_time, end_time),
|
||||
)
|
||||
|
||||
@@ -228,15 +236,19 @@ class RealtimeRepository:
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(target_time,),
|
||||
)
|
||||
# 1. 先删除该时间点的旧数据
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.node_simulation WHERE time = %s",
|
||||
"DELETE FROM realtime.node_results WHERE time = %s",
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY realtime.node_simulation (time, id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
"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(
|
||||
@@ -261,15 +273,19 @@ class RealtimeRepository:
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended(%s::text, 1))",
|
||||
(target_time,),
|
||||
)
|
||||
# 1. 先删除该时间点的旧数据
|
||||
cur.execute(
|
||||
"DELETE FROM realtime.node_simulation WHERE time = %s",
|
||||
"DELETE FROM realtime.node_results WHERE time = %s",
|
||||
(target_time,),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY realtime.node_simulation (time, id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
"COPY realtime.node_results (time, node_id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
@@ -289,7 +305,7 @@ class RealtimeRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM realtime.node_simulation WHERE time >= %s AND time <= %s AND id = %s",
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s",
|
||||
(start_time, end_time, node_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -302,7 +318,7 @@ 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_simulation WHERE time >= %s AND time <= %s",
|
||||
"SELECT * FROM realtime.node_results WHERE time >= %s AND time <= %s",
|
||||
(normalized_start_time, normalized_end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -320,7 +336,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT time, {} FROM realtime.node_simulation WHERE time >= %s AND time <= %s AND id = %s"
|
||||
"SELECT time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s AND node_id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -339,7 +355,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM realtime.node_simulation WHERE time >= %s AND time <= %s"
|
||||
"SELECT node_id, time, {} FROM realtime.node_results WHERE time >= %s AND time <= %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -347,7 +363,7 @@ class RealtimeRepository:
|
||||
rows = await cur.fetchall()
|
||||
result = defaultdict(list)
|
||||
for row in rows:
|
||||
result[row["id"]].append(
|
||||
result[row["node_id"]].append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return dict(result)
|
||||
@@ -365,7 +381,7 @@ class RealtimeRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"UPDATE realtime.node_simulation SET {} = %s WHERE time = %s AND id = %s"
|
||||
"UPDATE realtime.node_results SET {} = %s WHERE time = %s AND node_id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -377,7 +393,7 @@ class RealtimeRepository:
|
||||
):
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM realtime.node_simulation WHERE time >= %s AND time <= %s",
|
||||
"DELETE FROM realtime.node_results WHERE time >= %s AND time <= %s",
|
||||
(start_time, end_time),
|
||||
)
|
||||
|
||||
@@ -439,12 +455,15 @@ class RealtimeRepository:
|
||||
}
|
||||
)
|
||||
|
||||
# Insert data using batch methods
|
||||
if node_data:
|
||||
await RealtimeRepository.insert_nodes_batch(conn, node_data)
|
||||
# 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.
|
||||
async with conn.transaction():
|
||||
if node_data:
|
||||
await RealtimeRepository.insert_nodes_batch(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
await RealtimeRepository.insert_links_batch(conn, link_data)
|
||||
if link_data:
|
||||
await RealtimeRepository.insert_links_batch(conn, link_data)
|
||||
|
||||
@staticmethod
|
||||
def store_realtime_simulation_result_sync(
|
||||
@@ -502,12 +521,15 @@ class RealtimeRepository:
|
||||
}
|
||||
)
|
||||
|
||||
# Insert data using batch methods
|
||||
if node_data:
|
||||
RealtimeRepository.insert_nodes_batch_sync(conn, node_data)
|
||||
# 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.
|
||||
with conn.transaction():
|
||||
if node_data:
|
||||
RealtimeRepository.insert_nodes_batch_sync(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
RealtimeRepository.insert_links_batch_sync(conn, link_data)
|
||||
if link_data:
|
||||
RealtimeRepository.insert_links_batch_sync(conn, link_data)
|
||||
|
||||
@staticmethod
|
||||
async def query_all_record_by_time_property(
|
||||
|
||||
@@ -14,7 +14,7 @@ class ScadaRepository:
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
async with cur.copy(
|
||||
"COPY scada.scada_data (time, device_id, monitored_value, cleaned_value) FROM STDIN"
|
||||
"COPY scada.measurements (time, device_id, monitored_value, cleaned_value) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
@@ -35,7 +35,7 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scada.scada_data WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
@@ -49,7 +49,7 @@ class ScadaRepository:
|
||||
) -> List[dict]:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
cur.execute(
|
||||
"SELECT * FROM scada.scada_data WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
"SELECT * FROM scada.measurements WHERE device_id = ANY(%s) AND time >= %s AND time <= %s",
|
||||
(device_ids, start_time, end_time),
|
||||
)
|
||||
return cur.fetchall()
|
||||
@@ -63,12 +63,12 @@ class ScadaRepository:
|
||||
with conn.cursor(row_factory=dict_row) as cur:
|
||||
if before_time is None:
|
||||
cur.execute(
|
||||
"SELECT max(time) AS time FROM scada.scada_data WHERE device_id = ANY(%s)",
|
||||
"SELECT max(time) AS time FROM scada.measurements WHERE device_id = ANY(%s)",
|
||||
(device_ids,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT max(time) AS time FROM scada.scada_data "
|
||||
"SELECT max(time) AS time FROM scada.measurements "
|
||||
"WHERE device_id = ANY(%s) AND time <= %s",
|
||||
(device_ids, before_time),
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class ScadaRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT device_id, time, {} FROM scada.scada_data WHERE time >= %s AND time <= %s AND device_id = ANY(%s)"
|
||||
"SELECT device_id, time, {} FROM scada.measurements WHERE time >= %s AND time <= %s AND device_id = ANY(%s)"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -111,10 +111,10 @@ class ScadaRepository:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
update_query = sql.SQL(
|
||||
"UPDATE scada.scada_data SET {} = %s WHERE time = %s AND device_id = %s"
|
||||
"UPDATE scada.measurements SET {} = %s WHERE time = %s AND device_id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
insert_query = sql.SQL(
|
||||
"INSERT INTO scada.scada_data (time, device_id, {}) VALUES (%s, %s, %s)"
|
||||
"INSERT INTO scada.measurements (time, device_id, {}) VALUES (%s, %s, %s)"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
@@ -128,6 +128,6 @@ class ScadaRepository:
|
||||
):
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM scada.scada_data WHERE device_id = %s AND time >= %s AND time <= %s",
|
||||
"DELETE FROM scada.measurements WHERE device_id = %s AND time >= %s AND time <= %s",
|
||||
(device_id, start_time, end_time),
|
||||
)
|
||||
|
||||
@@ -1,710 +0,0 @@
|
||||
from typing import List, Any, Dict
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from psycopg import AsyncConnection, Connection, sql
|
||||
import app.services.globals as globals
|
||||
from app.services.time_api import parse_clock_duration_seconds, parse_utc_time
|
||||
|
||||
|
||||
class SchemeRepository:
|
||||
@staticmethod
|
||||
def _get_result_timestep(result_timestep_seconds: int | None) -> timedelta:
|
||||
if result_timestep_seconds is not None:
|
||||
if result_timestep_seconds <= 0:
|
||||
raise ValueError("result_timestep_seconds must be greater than 0.")
|
||||
return timedelta(seconds=result_timestep_seconds)
|
||||
|
||||
timestep_seconds = parse_clock_duration_seconds(
|
||||
globals.hydraulic_timestep,
|
||||
field_name="HYDRAULIC TIMESTEP",
|
||||
)
|
||||
if timestep_seconds <= 0:
|
||||
raise ValueError("HYDRAULIC TIMESTEP must be greater than 0.")
|
||||
return timedelta(seconds=timestep_seconds)
|
||||
|
||||
# --- Link Simulation ---
|
||||
|
||||
@staticmethod
|
||||
async def insert_links_batch(conn: AsyncConnection, data: List[dict]):
|
||||
"""Batch insert for scheme.link_simulation using DELETE then COPY for performance."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 获取批次中所有不同的时间点
|
||||
all_times = list(set(item["time"] for item in data))
|
||||
target_scheme_type = data[0]["scheme_type"]
|
||||
target_scheme_name = data[0]["scheme_name"]
|
||||
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
# 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据
|
||||
await cur.execute(
|
||||
"DELETE FROM scheme.link_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s",
|
||||
(all_times, target_scheme_type, target_scheme_name),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY scheme.link_simulation (time, scheme_type, scheme_name, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
item["scheme_type"],
|
||||
item["scheme_name"],
|
||||
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 insert_links_batch_sync(conn: Connection, data: List[dict]):
|
||||
"""Batch insert for scheme.link_simulation using DELETE then COPY for performance (sync version)."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 获取批次中所有不同的时间点
|
||||
all_times = list(set(item["time"] for item in data))
|
||||
target_scheme_type = data[0]["scheme_type"]
|
||||
target_scheme_name = data[0]["scheme_name"]
|
||||
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
# 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据
|
||||
cur.execute(
|
||||
"DELETE FROM scheme.link_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s",
|
||||
(all_times, target_scheme_type, target_scheme_name),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY scheme.link_simulation (time, scheme_type, scheme_name, id, flow, friction, headloss, quality, reaction, setting, status, velocity) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
item["scheme_type"],
|
||||
item["scheme_name"],
|
||||
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 get_link_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
link_id: str,
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s",
|
||||
(scheme_type, scheme_name, start_time, end_time, link_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_links_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s",
|
||||
(scheme_type, scheme_name, start_time, end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_link_field_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
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 scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
query, (scheme_type, scheme_name, 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_links_field_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
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:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (scheme_type, scheme_name, start_time, end_time))
|
||||
rows = await cur.fetchall()
|
||||
result = defaultdict(list)
|
||||
for row in rows:
|
||||
result[row["id"]].append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return dict(result)
|
||||
|
||||
@staticmethod
|
||||
async def update_link_field(
|
||||
conn: AsyncConnection,
|
||||
time: datetime,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
link_id: str,
|
||||
field: str,
|
||||
value: Any,
|
||||
):
|
||||
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(
|
||||
"UPDATE scheme.link_simulation SET {} = %s WHERE time = %s AND scheme_type = %s AND scheme_name = %s AND id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (value, time, scheme_type, scheme_name, link_id))
|
||||
|
||||
@staticmethod
|
||||
async def delete_links_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
):
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM scheme.link_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s",
|
||||
(scheme_type, scheme_name, start_time, end_time),
|
||||
)
|
||||
|
||||
# --- Node Simulation ---
|
||||
|
||||
@staticmethod
|
||||
async def insert_nodes_batch(conn: AsyncConnection, data: List[dict]):
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 获取批次中所有不同的时间点
|
||||
all_times = list(set(item["time"] for item in data))
|
||||
target_scheme_type = data[0]["scheme_type"]
|
||||
target_scheme_name = data[0]["scheme_name"]
|
||||
|
||||
# 使用事务确保原子性
|
||||
async with conn.transaction():
|
||||
async with conn.cursor() as cur:
|
||||
# 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据
|
||||
await cur.execute(
|
||||
"DELETE FROM scheme.node_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s",
|
||||
(all_times, target_scheme_type, target_scheme_name),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
async with cur.copy(
|
||||
"COPY scheme.node_simulation (time, scheme_type, scheme_name, id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
await copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
item["scheme_type"],
|
||||
item["scheme_name"],
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def insert_nodes_batch_sync(conn: Connection, data: List[dict]):
|
||||
if not data:
|
||||
return
|
||||
|
||||
# 获取批次中所有不同的时间点
|
||||
all_times = list(set(item["time"] for item in data))
|
||||
target_scheme_type = data[0]["scheme_type"]
|
||||
target_scheme_name = data[0]["scheme_name"]
|
||||
|
||||
# 使用事务确保原子性
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
# 1. 删除该批次涉及的所有时间点、scheme_type、scheme_name 的旧数据
|
||||
cur.execute(
|
||||
"DELETE FROM scheme.node_simulation WHERE time = ANY(%s) AND scheme_type = %s AND scheme_name = %s",
|
||||
(all_times, target_scheme_type, target_scheme_name),
|
||||
)
|
||||
|
||||
# 2. 使用 COPY 快速写入新数据
|
||||
with cur.copy(
|
||||
"COPY scheme.node_simulation (time, scheme_type, scheme_name, id, actual_demand, total_head, pressure, quality) FROM STDIN"
|
||||
) as copy:
|
||||
for item in data:
|
||||
copy.write_row(
|
||||
(
|
||||
item["time"],
|
||||
item["scheme_type"],
|
||||
item["scheme_name"],
|
||||
item["id"],
|
||||
item.get("actual_demand"),
|
||||
item.get("total_head"),
|
||||
item.get("pressure"),
|
||||
item.get("quality"),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_node_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
node_id: str,
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s",
|
||||
(scheme_type, scheme_name, start_time, end_time, node_id),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_nodes_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> List[dict]:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s",
|
||||
(scheme_type, scheme_name, start_time, end_time),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
@staticmethod
|
||||
async def get_node_field_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
node_id: str,
|
||||
field: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
# Validate field name to prevent SQL injection
|
||||
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 scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s AND id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
query, (scheme_type, scheme_name, 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_nodes_field_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
field: str,
|
||||
) -> dict:
|
||||
# Validate field name to prevent SQL injection
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"SELECT id, time, {} FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (scheme_type, scheme_name, start_time, end_time))
|
||||
rows = await cur.fetchall()
|
||||
result = defaultdict(list)
|
||||
for row in rows:
|
||||
result[row["id"]].append(
|
||||
{"time": row["time"].isoformat(), "value": row[field]}
|
||||
)
|
||||
return dict(result)
|
||||
|
||||
@staticmethod
|
||||
async def update_node_field(
|
||||
conn: AsyncConnection,
|
||||
time: datetime,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
node_id: str,
|
||||
field: str,
|
||||
value: Any,
|
||||
):
|
||||
valid_fields = {"actual_demand", "total_head", "pressure", "quality"}
|
||||
if field not in valid_fields:
|
||||
raise ValueError(f"Invalid field: {field}")
|
||||
|
||||
query = sql.SQL(
|
||||
"UPDATE scheme.node_simulation SET {} = %s WHERE time = %s AND scheme_type = %s AND scheme_name = %s AND id = %s"
|
||||
).format(sql.Identifier(field))
|
||||
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, (value, time, scheme_type, scheme_name, node_id))
|
||||
|
||||
@staticmethod
|
||||
async def delete_nodes_by_scheme_and_time_range(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
):
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM scheme.node_simulation WHERE scheme_type = %s AND scheme_name = %s AND time >= %s AND time <= %s",
|
||||
(scheme_type, scheme_name, start_time, end_time),
|
||||
)
|
||||
|
||||
# --- 复合查询 ---
|
||||
|
||||
@staticmethod
|
||||
async def store_scheme_simulation_result(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
node_result_list: List[Dict[str, any]],
|
||||
link_result_list: List[Dict[str, any]],
|
||||
result_start_time: str,
|
||||
num_periods: int = 1,
|
||||
result_timestep_seconds: int | None = None,
|
||||
):
|
||||
"""
|
||||
Store scheme simulation results to TimescaleDB.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
scheme_type: Scheme type
|
||||
scheme_name: Scheme name
|
||||
node_result_list: List of node simulation results
|
||||
link_result_list: List of link simulation results
|
||||
result_start_time: Start time for the results (ISO format string)
|
||||
"""
|
||||
simulation_time = parse_utc_time(
|
||||
result_start_time, field_name="result_start_time"
|
||||
)
|
||||
|
||||
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
|
||||
|
||||
# Prepare node data for batch insert
|
||||
node_data = []
|
||||
for node_result in node_result_list:
|
||||
node_id = node_result.get("node")
|
||||
result_rows = node_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = result_rows[period_index]
|
||||
node_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
"scheme_type": scheme_type,
|
||||
"scheme_name": scheme_name,
|
||||
"id": node_id,
|
||||
"actual_demand": data.get("demand"),
|
||||
"total_head": data.get("head"),
|
||||
"pressure": data.get("pressure"),
|
||||
"quality": data.get("quality"),
|
||||
}
|
||||
)
|
||||
|
||||
# Prepare link data for batch insert
|
||||
link_data = []
|
||||
for link_result in link_result_list:
|
||||
link_id = link_result.get("link")
|
||||
result_rows = link_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = result_rows[period_index]
|
||||
link_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
"scheme_type": scheme_type,
|
||||
"scheme_name": scheme_name,
|
||||
"id": link_id,
|
||||
"flow": data.get("flow"),
|
||||
"friction": data.get("friction"),
|
||||
"headloss": data.get("headloss"),
|
||||
"quality": data.get("quality"),
|
||||
"reaction": data.get("reaction"),
|
||||
"setting": data.get("setting"),
|
||||
"status": data.get("status"),
|
||||
"velocity": data.get("velocity"),
|
||||
}
|
||||
)
|
||||
|
||||
# Insert data using batch methods
|
||||
if node_data:
|
||||
await SchemeRepository.insert_nodes_batch(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
await SchemeRepository.insert_links_batch(conn, link_data)
|
||||
|
||||
@staticmethod
|
||||
def store_scheme_simulation_result_sync(
|
||||
conn: Connection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
node_result_list: List[Dict[str, any]],
|
||||
link_result_list: List[Dict[str, any]],
|
||||
result_start_time: str,
|
||||
num_periods: int = 1,
|
||||
result_timestep_seconds: int | None = None,
|
||||
):
|
||||
"""
|
||||
Store scheme simulation results to TimescaleDB (sync version).
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
scheme_type: Scheme type
|
||||
scheme_name: Scheme name
|
||||
node_result_list: List of node simulation results
|
||||
link_result_list: List of link simulation results
|
||||
result_start_time: Start time for the results (ISO format string)
|
||||
"""
|
||||
simulation_time = parse_utc_time(
|
||||
result_start_time, field_name="result_start_time"
|
||||
)
|
||||
|
||||
timestep = SchemeRepository._get_result_timestep(result_timestep_seconds)
|
||||
|
||||
# Prepare node data for batch insert
|
||||
node_data = []
|
||||
for node_result in node_result_list:
|
||||
node_id = node_result.get("node")
|
||||
result_rows = node_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = result_rows[period_index]
|
||||
node_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
"scheme_type": scheme_type,
|
||||
"scheme_name": scheme_name,
|
||||
"id": node_id,
|
||||
"actual_demand": data.get("demand"),
|
||||
"total_head": data.get("head"),
|
||||
"pressure": data.get("pressure"),
|
||||
"quality": data.get("quality"),
|
||||
}
|
||||
)
|
||||
|
||||
# Prepare link data for batch insert
|
||||
link_data = []
|
||||
for link_result in link_result_list:
|
||||
link_id = link_result.get("link")
|
||||
result_rows = link_result.get("result", [])
|
||||
for period_index in range(min(num_periods, len(result_rows))):
|
||||
current_time = simulation_time + (timestep * period_index)
|
||||
data = result_rows[period_index]
|
||||
link_data.append(
|
||||
{
|
||||
"time": current_time,
|
||||
"scheme_type": scheme_type,
|
||||
"scheme_name": scheme_name,
|
||||
"id": link_id,
|
||||
"flow": data.get("flow"),
|
||||
"friction": data.get("friction"),
|
||||
"headloss": data.get("headloss"),
|
||||
"quality": data.get("quality"),
|
||||
"reaction": data.get("reaction"),
|
||||
"setting": data.get("setting"),
|
||||
"status": data.get("status"),
|
||||
"velocity": data.get("velocity"),
|
||||
}
|
||||
)
|
||||
|
||||
# Insert data using batch methods
|
||||
if node_data:
|
||||
SchemeRepository.insert_nodes_batch_sync(conn, node_data)
|
||||
|
||||
if link_data:
|
||||
SchemeRepository.insert_links_batch_sync(conn, link_data)
|
||||
|
||||
@staticmethod
|
||||
async def query_all_record_by_scheme_time_property(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
query_time: str,
|
||||
type: str,
|
||||
property: str,
|
||||
) -> list:
|
||||
"""
|
||||
Query all records by scheme, time and property from TimescaleDB.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
scheme_type: Scheme type
|
||||
scheme_name: Scheme name
|
||||
query_time: Time to query (ISO format string)
|
||||
type: Type of data ("node" or "link")
|
||||
property: Property/field to query
|
||||
|
||||
Returns:
|
||||
List of records matching the criteria
|
||||
"""
|
||||
target_time = parse_utc_time(query_time, field_name="query_time")
|
||||
|
||||
# Create time range: query_time ± 1 second
|
||||
start_time = target_time - timedelta(seconds=1)
|
||||
end_time = target_time + timedelta(seconds=1)
|
||||
|
||||
# Query based on type
|
||||
if type.lower() == "node":
|
||||
data = await SchemeRepository.get_nodes_field_by_scheme_and_time_range(
|
||||
conn, scheme_type, scheme_name, start_time, end_time, property
|
||||
)
|
||||
elif type.lower() == "link":
|
||||
data = await SchemeRepository.get_links_field_by_scheme_and_time_range(
|
||||
conn, scheme_type, scheme_name, start_time, end_time, property
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid type: {type}. Must be 'node' or 'link'")
|
||||
|
||||
# Format the results
|
||||
# Format the results
|
||||
result = []
|
||||
for id, items in data.items():
|
||||
for item in items:
|
||||
result.append({"ID": id, "value": item["value"]})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def query_scheme_simulation_result_by_id_time(
|
||||
conn: AsyncConnection,
|
||||
scheme_type: str,
|
||||
scheme_name: str,
|
||||
id: str,
|
||||
type: str,
|
||||
query_time: str,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Query scheme simulation results by id and time from TimescaleDB.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
scheme_type: Scheme type
|
||||
scheme_name: Scheme name
|
||||
id: The id of the node or link
|
||||
type: Type of data ("node" or "link")
|
||||
query_time: Time to query (ISO format string)
|
||||
|
||||
Returns:
|
||||
List of records matching the criteria
|
||||
"""
|
||||
target_time = parse_utc_time(query_time, field_name="query_time")
|
||||
|
||||
# Create time range: query_time ± 1 second
|
||||
start_time = target_time - timedelta(seconds=1)
|
||||
end_time = target_time + timedelta(seconds=1)
|
||||
|
||||
# Query based on type
|
||||
if type.lower() == "node":
|
||||
return await SchemeRepository.get_node_by_scheme_and_time_range(
|
||||
conn, scheme_type, scheme_name, start_time, end_time, id
|
||||
)
|
||||
elif type.lower() == "link":
|
||||
return await SchemeRepository.get_link_by_scheme_and_time_range(
|
||||
conn, scheme_type, scheme_name, start_time, end_time, id
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid type: {type}. Must be 'node' or 'link'")
|
||||
@@ -0,0 +1,95 @@
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from threading import RLock
|
||||
|
||||
from psycopg import Connection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.infra.db.project_routing import get_project_timescale_pgconn_string
|
||||
|
||||
|
||||
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
|
||||
_pool_conninfo: dict[str, str] = {}
|
||||
_pool_borrows: dict[str, int] = {}
|
||||
_lock = RLock()
|
||||
|
||||
|
||||
def _evict_idle_pools(*, protected: str | None = None) -> None:
|
||||
limit = max(1, settings.PROJECT_TS_CACHE_SIZE)
|
||||
while len(_pools) > limit:
|
||||
candidate = next(
|
||||
(key for key in _pools if key != protected and _pool_borrows.get(key, 0) == 0),
|
||||
None,
|
||||
)
|
||||
if candidate is None:
|
||||
return
|
||||
pool = _pools.pop(candidate)
|
||||
_pool_conninfo.pop(candidate, None)
|
||||
_pool_borrows.pop(candidate, None)
|
||||
if not pool.closed:
|
||||
pool.close()
|
||||
|
||||
|
||||
def get_timescale_pool(db_name: str) -> ConnectionPool:
|
||||
conninfo = get_project_timescale_pgconn_string(db_name=db_name)
|
||||
with _lock:
|
||||
pool = _pools.get(db_name)
|
||||
if pool is not None and _pool_conninfo.get(db_name) == conninfo and not pool.closed:
|
||||
_pools.move_to_end(db_name)
|
||||
return pool
|
||||
if pool is not None and not pool.closed:
|
||||
if _pool_borrows.get(db_name, 0):
|
||||
raise RuntimeError(f"Cannot replace active TimescaleDB pool {db_name!r}")
|
||||
pool.close()
|
||||
pool = ConnectionPool(
|
||||
conninfo=conninfo,
|
||||
min_size=settings.PROJECT_TS_POOL_MIN_SIZE,
|
||||
max_size=settings.PROJECT_TS_POOL_MAX_SIZE,
|
||||
kwargs={"row_factory": dict_row},
|
||||
open=True,
|
||||
)
|
||||
_pools[db_name] = pool
|
||||
_pool_conninfo[db_name] = conninfo
|
||||
_pool_borrows.setdefault(db_name, 0)
|
||||
_evict_idle_pools(protected=db_name)
|
||||
return pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def timescale_connection(db_name: str) -> Iterator[Connection]:
|
||||
with _lock:
|
||||
pool = get_timescale_pool(db_name)
|
||||
_pool_borrows[db_name] = _pool_borrows.get(db_name, 0) + 1
|
||||
try:
|
||||
with pool.connection() as conn:
|
||||
yield conn
|
||||
finally:
|
||||
with _lock:
|
||||
_pool_borrows[db_name] -= 1
|
||||
_evict_idle_pools()
|
||||
|
||||
|
||||
def close_timescale_pool(db_name: str) -> None:
|
||||
with _lock:
|
||||
if _pool_borrows.get(db_name, 0):
|
||||
raise RuntimeError(f"Cannot close active TimescaleDB pool {db_name!r}")
|
||||
pool = _pools.pop(db_name, None)
|
||||
_pool_conninfo.pop(db_name, None)
|
||||
_pool_borrows.pop(db_name, None)
|
||||
if pool is not None and not pool.closed:
|
||||
pool.close()
|
||||
|
||||
|
||||
def close_all_timescale_pools() -> None:
|
||||
"""Close every synchronous TimescaleDB pool."""
|
||||
with _lock:
|
||||
pools = list(_pools.values())
|
||||
_pools.clear()
|
||||
_pool_conninfo.clear()
|
||||
_pool_borrows.clear()
|
||||
for pool in pools:
|
||||
if not pool.closed:
|
||||
pool.close()
|
||||
Reference in New Issue
Block a user