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