refactor(db)!: finalize pooled WNDB v2 migration

This commit is contained in:
2026-08-27 17:26:22 +08:00
parent fa188af0b1
commit b74799a39d
105 changed files with 4988 additions and 5565 deletions
+3 -4
View File
@@ -7,7 +7,7 @@ from ..model.reservoirs import unset_reservoir_by_pattern
from ..model.tanks import unset_tank_by_curve
from ..model.pumps import unset_pump_by_curve, unset_pump_by_pattern
from ..model.tags import delete_tag_by_node, delete_tag_by_link
from ..model.demands import delete_demand_by_junction, unset_demand_by_pattern
from ..model.demands import delete_demand_by_junction
from ..model.status import delete_status_by_link
from ..model.energy import delete_pump_energy_by_pump, unset_pump_energy_by_pattern, unset_pump_energy_by_curve
from ..model.emitters import delete_emitter_by_junction
@@ -167,7 +167,6 @@ def expand_pattern_delete(name: str, cs: ChangeSet) -> ChangeSet:
result.merge(unset_reservoir_by_pattern(name, id))
result.merge(unset_pump_by_pattern(name, id))
result.merge(unset_demand_by_pattern(name, id))
result.merge(unset_pump_energy_by_pattern(name, id))
result.merge(unset_source_by_pattern(name, id))
result.merge(cs)
@@ -191,7 +190,7 @@ def expand_curve_delete(name: str, cs: ChangeSet) -> ChangeSet:
return result
def expand_legacy_options_update(cs: ChangeSet) -> ChangeSet:
def expand_v2_options_update(cs: ChangeSet) -> ChangeSet:
cs.operations[0]['operation'] = API_UPDATE
cs.operations[0]['type'] = 'option'
new_cs = cs
@@ -239,6 +238,6 @@ _DELETE_REWRITERS: dict[str, DeleteRewriter] = {
}
_UPDATE_REWRITERS: dict[str, UpdateRewriter] = {
"option": expand_legacy_options_update,
"option": expand_v2_options_update,
"option_v3": expand_v3_options_update,
}
+6 -5
View File
@@ -2,13 +2,14 @@
from collections.abc import Callable
from ..core.connection import project_transaction
from ..core.database import (
API_ADD,
API_DELETE,
API_UPDATE,
ChangeSet,
refresh_materialized_views,
changes_affect_materialized_views,
model_mutation_transaction,
refresh_materialized_views_after_commit,
)
from ..gis.backdrop import set_backdrop
from ..gis.labels import add_label, delete_label, set_label
@@ -131,7 +132,7 @@ def _execute_delete_command(name: str, change_set: ChangeSet) -> ChangeSet:
def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
with project_transaction(name):
with model_mutation_transaction(name):
rewritten = ChangeSet()
for operation in change_set.operations:
rewritten.merge(expand_command(name, ChangeSet(operation)))
@@ -146,8 +147,8 @@ def execute_batch_commands(name: str, change_set: ChangeSet) -> ChangeSet:
elif operation_type == API_DELETE:
result.merge(_execute_delete_command(name, ChangeSet(operation)))
if rewritten.operations:
refresh_materialized_views(name)
if changes_affect_materialized_views(rewritten):
refresh_materialized_views_after_commit(name)
return result
+20
View File
@@ -11,6 +11,7 @@ from psycopg_pool import ConnectionPool
from app.core.config import settings
from app.infra.db.project_routing import get_project_pgconn_string
_check_connection = ConnectionPool.check_connection
_pools: OrderedDict[str, ConnectionPool] = OrderedDict()
_pool_conninfo: dict[str, str] = {}
_pool_borrows: dict[str, int] = {}
@@ -21,6 +22,10 @@ _active_project_connection: ContextVar[tuple[str, Connection] | None] = ContextV
"wndb_active_project_connection",
default=None,
)
_active_model_mutation_locks: ContextVar[frozenset[str]] = ContextVar(
"wndb_active_model_mutation_locks",
default=frozenset(),
)
def _close_pool(pool: ConnectionPool) -> None:
@@ -78,6 +83,7 @@ def get_project_pool(name: str) -> ConnectionPool:
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
max_size=settings.PROJECT_PG_POOL_SIZE + settings.PROJECT_PG_MAX_OVERFLOW,
kwargs={"autocommit": True, "row_factory": dict_row},
check=_check_connection,
open=True,
)
_pools[name] = pool
@@ -140,6 +146,7 @@ def get_admin_pool() -> ConnectionPool:
min_size=settings.PROJECT_PG_POOL_MIN_SIZE,
max_size=settings.PROJECT_PG_POOL_SIZE,
kwargs={"autocommit": True, "row_factory": dict_row},
check=_check_connection,
open=True,
)
_admin_pools[conninfo] = pool
@@ -192,10 +199,12 @@ def project_transaction(name: str) -> Iterator[Connection]:
try:
with pool.connection() as conn:
token = _active_project_connection.set((name, conn))
lock_token = _active_model_mutation_locks.set(frozenset())
try:
with conn.transaction():
yield conn
finally:
_active_model_mutation_locks.reset(lock_token)
_active_project_connection.reset(token)
finally:
with _registry_lock:
@@ -208,6 +217,17 @@ def is_project_transaction_active(name: str) -> bool:
return active is not None and active[0] == name
def is_model_mutation_lock_active(name: str) -> bool:
"""Return whether the current project transaction already owns its model lock."""
return name in _active_model_mutation_locks.get()
def mark_model_mutation_lock_active(name: str) -> None:
"""Record a transaction-scoped advisory lock to avoid duplicate round trips."""
locks = _active_model_mutation_locks.get()
_active_model_mutation_locks.set(locks | {name})
@contextmanager
def admin_connection() -> Iterator[Connection]:
"""Borrow a PostgreSQL administration connection from its pool."""
+103 -6
View File
@@ -1,10 +1,19 @@
from collections.abc import Mapping, Sequence
from typing import Any
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from typing import Any, Callable
from psycopg import sql
from psycopg.rows import Row, dict_row
from .connection import is_project_transaction_active, project_connection
from app.infra.db.project_routing import get_project_database_name
from .connection import (
is_model_mutation_lock_active,
is_project_transaction_active,
mark_model_mutation_lock_active,
project_connection,
project_transaction,
)
API_ADD = "add"
API_UPDATE = "update"
@@ -61,6 +70,20 @@ class DatabaseCommand:
self.sql = statement
self.changes = changes
class MaterializedViewRefreshAfterCommitError(RuntimeError):
"""Report a failed view refresh without implying that the write rolled back."""
changes_committed = True
def __init__(self, project: str) -> None:
self.project = project
super().__init__(
f"Project {project!r} changes were committed, but materialized view "
"refresh failed"
)
QueryParams = Sequence[Any] | Mapping[str, Any]
@@ -78,6 +101,27 @@ def _execute(cur, query: str, params: QueryParams | None = None):
return cur.execute(query, params) if params is not None else cur.execute(query)
def acquire_model_mutation_lock(conn, name: str) -> None:
"""Serialize model replacement and ordinary WNDB mutations per database."""
if is_model_mutation_lock_active(name):
return
physical_name = get_project_database_name(name)
with conn.cursor() as cur:
cur.execute(
"select pg_advisory_xact_lock(hashtextextended(%s, 0))",
(f"tjwater:wndb:model:{physical_name}",),
)
mark_model_mutation_lock_active(name)
@contextmanager
def model_mutation_transaction(name: str) -> Iterator[Any]:
"""Open a project transaction and acquire its model lock before reading."""
with project_transaction(name) as conn:
acquire_model_mutation_lock(conn, name)
yield conn
def read(name: str, query: str, params: QueryParams | None = None) -> Row:
with project_connection(name) as conn, conn.cursor(row_factory=dict_row) as cur:
_execute(cur, query, params)
@@ -104,8 +148,15 @@ def try_read(
def write(name: str, query: str, params: QueryParams | None = None) -> None:
with project_connection(name) as conn, conn.cursor() as cur:
_execute(cur, query, params)
connection_context = (
project_connection(name)
if is_project_transaction_active(name)
else model_mutation_transaction(name)
)
with connection_context as conn:
acquire_model_mutation_lock(conn, name)
with conn.cursor() as cur:
_execute(cur, query, params)
def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None:
@@ -114,6 +165,13 @@ def refresh_materialized_views(name: str, *, concurrently: bool = True) -> None:
cur.execute("CALL gis.refresh_all_materialized_views(%s)", (concurrently,))
def refresh_materialized_views_after_commit(name: str) -> None:
try:
refresh_materialized_views(name)
except Exception as exc:
raise MaterializedViewRefreshAfterCommitError(name) from exc
_MATERIALIZED_VIEW_SOURCES = (
"network.nodes",
"network.junctions",
@@ -135,9 +193,48 @@ def _affects_materialized_views(command: DatabaseCommand) -> bool:
return any(source in statement for source in _MATERIALIZED_VIEW_SOURCES)
_MATERIALIZED_VIEW_ELEMENT_TYPES = frozenset(
{
"junction",
"reservoir",
"tank",
"pipe",
"pump",
"valve",
"demand",
"vertex",
}
)
def changes_affect_materialized_views(change_set: ChangeSet) -> bool:
"""Return whether a dispatched WNDB batch changes a published GIS source."""
return any(
operation.get("type") in _MATERIALIZED_VIEW_ELEMENT_TYPES
for operation in change_set.operations
)
def execute_command(name: str, command: DatabaseCommand) -> ChangeSet:
"""Apply a model mutation without the removed database undo/redo journal."""
write(name, command.sql)
if _affects_materialized_views(command) and not is_project_transaction_active(name):
refresh_materialized_views(name)
refresh_materialized_views_after_commit(name)
return ChangeSet.from_list(command.changes)
def execute_locked_command(
name: str,
builder: Callable[[], DatabaseCommand | None],
) -> ChangeSet:
"""Build a read-modify-write command only after acquiring the model lock."""
nested_transaction = is_project_transaction_active(name)
command: DatabaseCommand | None = None
with model_mutation_transaction(name):
command = builder()
if command is None:
return ChangeSet()
result = execute_command(name, command)
if not nested_transaction and _affects_materialized_views(command):
refresh_materialized_views_after_commit(name)
return result
+284
View File
@@ -0,0 +1,284 @@
from psycopg import Connection, sql
from .connection import project_connection, project_transaction
from .database import acquire_model_mutation_lock
_MODEL_SCHEMAS = ("network", "gis")
_ALLOWED_EXTERNAL_REFERENCES = {
("analysis", "results", "network", "nodes"),
("analysis", "results", "network", "links"),
("asset", "scada_devices", "network", "nodes"),
("asset", "scada_devices", "network", "links"),
}
def _model_tables(conn: Connection) -> list[tuple[str, str]]:
with conn.cursor() as cur:
cur.execute(
"""
select n.nspname as schema_name, c.relname as table_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = any(%s) and c.relkind in ('r', 'p')
order by n.nspname, c.relname
""",
(list(_MODEL_SCHEMAS),),
)
return [(row["schema_name"], row["table_name"]) for row in cur.fetchall()]
def _copy_order(
conn: Connection, tables: list[tuple[str, str]]
) -> list[tuple[str, str]]:
table_set = set(tables)
dependencies: dict[tuple[str, str], set[tuple[str, str]]] = {
table: set() for table in tables
}
with conn.cursor() as cur:
cur.execute(
"""
select source_ns.nspname as source_schema,
source.relname as source_table,
target_ns.nspname as target_schema,
target.relname as target_table
from pg_constraint constraint_row
join pg_class source on source.oid = constraint_row.conrelid
join pg_namespace source_ns on source_ns.oid = source.relnamespace
join pg_class target on target.oid = constraint_row.confrelid
join pg_namespace target_ns on target_ns.oid = target.relnamespace
where constraint_row.contype = 'f'
and source_ns.nspname = any(%s)
and target_ns.nspname = any(%s)
""",
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
)
for row in cur.fetchall():
source = (row["source_schema"], row["source_table"])
target = (row["target_schema"], row["target_table"])
if source in table_set and target in table_set and source != target:
dependencies[source].add(target)
ordered: list[tuple[str, str]] = []
remaining = set(tables)
while remaining:
ready = sorted(
table for table in remaining if not (dependencies[table] & remaining)
)
if not ready:
cycle = ", ".join(f"{schema}.{table}" for schema, table in sorted(remaining))
raise RuntimeError(f"Model table foreign-key cycle detected: {cycle}")
ordered.extend(ready)
remaining.difference_update(ready)
return ordered
def _table_columns(
conn: Connection, schema_name: str, table_name: str
) -> list[str]:
with conn.cursor() as cur:
cur.execute(
"""
select column_name
from information_schema.columns
where table_schema = %s and table_name = %s
and is_generated = 'NEVER'
order by ordinal_position
""",
(schema_name, table_name),
)
return [row["column_name"] for row in cur.fetchall()]
def _external_references(conn: Connection) -> set[tuple[str, str, str, str]]:
with conn.cursor() as cur:
cur.execute(
"""
select source_ns.nspname as source_schema,
source.relname as source_table,
target_ns.nspname as target_schema,
target.relname as target_table
from pg_constraint constraint_row
join pg_class source on source.oid = constraint_row.conrelid
join pg_namespace source_ns on source_ns.oid = source.relnamespace
join pg_class target on target.oid = constraint_row.confrelid
join pg_namespace target_ns on target_ns.oid = target.relnamespace
where constraint_row.contype = 'f'
and target_ns.nspname = any(%s)
and source_ns.nspname <> all(%s)
""",
(list(_MODEL_SCHEMAS), list(_MODEL_SCHEMAS)),
)
return {
(
row["source_schema"],
row["source_table"],
row["target_schema"],
row["target_table"],
)
for row in cur.fetchall()
}
def _copy_table(
source_conn: Connection,
target_conn: Connection,
schema_name: str,
table_name: str,
columns: list[str],
) -> None:
relation = sql.Identifier(schema_name, table_name)
column_list = sql.SQL(", ").join(map(sql.Identifier, columns))
copy_out = sql.SQL("copy {} ({}) to stdout").format(relation, column_list)
copy_in = sql.SQL("copy {} ({}) from stdin").format(relation, column_list)
with source_conn.cursor().copy(copy_out) as source_copy:
with target_conn.cursor().copy(copy_in) as target_copy:
for chunk in source_copy:
target_copy.write(chunk)
def replace_project_model(
target_project: str,
source_project: str,
*,
copy_source_scada: bool = False,
) -> None:
"""Atomically replace WNDB/GIS model tables from a validated staging DB.
Business and analysis tables remain in the target database. Historical
analysis rows keep references that still exist and become element-neutral
when an element disappeared. SCADA devices are retained only when their
bound node or link still exists in the replacement model.
``copy_source_scada`` is used only for temporary project clones. Normal INP
replacement keeps the target project's existing device mappings and drops
mappings whose model element no longer exists.
"""
with project_connection(source_project) as source_conn, source_conn.transaction():
with source_conn.cursor() as cur:
cur.execute("set transaction isolation level repeatable read, read only")
source_tables = _model_tables(source_conn)
source_columns = {
table: _table_columns(source_conn, *table) for table in source_tables
}
source_scada_columns = (
_table_columns(source_conn, "asset", "scada_devices")
if copy_source_scada
else []
)
copy_order = _copy_order(source_conn, source_tables)
with project_transaction(target_project) as target_conn:
acquire_model_mutation_lock(target_conn, target_project)
target_tables = _model_tables(target_conn)
if set(target_tables) != set(source_tables):
missing = sorted(set(source_tables) - set(target_tables))
extra = sorted(set(target_tables) - set(source_tables))
raise RuntimeError(
f"Staging/target model schema mismatch; missing={missing}, extra={extra}"
)
for table, columns in source_columns.items():
if _table_columns(target_conn, *table) != columns:
raise RuntimeError(
f"Staging/target columns differ for {table[0]}.{table[1]}"
)
if copy_source_scada and _table_columns(
target_conn, "asset", "scada_devices"
) != source_scada_columns:
raise RuntimeError(
"Source/target columns differ for asset.scada_devices"
)
unexpected = _external_references(target_conn) - _ALLOWED_EXTERNAL_REFERENCES
if unexpected:
formatted = ", ".join(
f"{source_schema}.{source_table}->{target_schema}.{target_table}"
for source_schema, source_table, target_schema, target_table in sorted(
unexpected
)
)
raise RuntimeError(
f"Model replacement has unsupported external references: {formatted}"
)
with target_conn.cursor() as cur:
if not copy_source_scada:
cur.execute(
"create temporary table model_scada_snapshot on commit drop "
"as table asset.scada_devices"
)
cur.execute("delete from asset.scada_devices")
cur.execute(
"create temporary table model_result_refs on commit drop as "
"select result_id, node_id, link_id from analysis.results"
)
cur.execute("update analysis.results set node_id = null, link_id = null")
for schema_name, table_name in reversed(copy_order):
cur.execute(
sql.SQL("delete from {}").format(
sql.Identifier(schema_name, table_name)
)
)
for schema_name, table_name in copy_order:
_copy_table(
source_conn,
target_conn,
schema_name,
table_name,
source_columns[(schema_name, table_name)],
)
if copy_source_scada:
_copy_table(
source_conn,
target_conn,
"asset",
"scada_devices",
source_scada_columns,
)
with target_conn.cursor() as cur:
if not copy_source_scada:
cur.execute(
"""
insert into asset.scada_devices
select snapshot.*
from model_scada_snapshot snapshot
where (
snapshot.node_id is not null
and exists (
select 1 from network.nodes node
where node.id = snapshot.node_id
)
) or (
snapshot.link_id is not null
and exists (
select 1 from network.links link
where link.id = snapshot.link_id
)
)
"""
)
cur.execute(
"""
update analysis.results result
set node_id = case
when exists (
select 1 from network.nodes node
where node.id = refs.node_id
) then refs.node_id
else null
end,
link_id = case
when exists (
select 1 from network.links link
where link.id = refs.link_id
) then refs.link_id
else null
end
from model_result_refs refs
where refs.result_id = result.result_id
"""
)
+222 -62
View File
@@ -1,13 +1,46 @@
from collections.abc import Iterable
from contextlib import contextmanager
import re
from uuid import uuid4
from psycopg import sql
from psycopg.rows import dict_row
from app.core.config import settings
from app.infra.db.project_routing import (
get_project_database_name,
get_project_template_database_name,
)
from .connection import (
admin_connection,
close_project_pool,
get_project_pool,
is_project_pool_open,
)
_server_databases = ["template0", "template1", "postgres", "project"]
_SERVER_DATABASES = frozenset({"template0", "template1", "postgres", "project"})
_TEMPORARY_DATABASE_PREFIX = "tjw_tmp_"
def _protected_databases() -> frozenset[str]:
return _SERVER_DATABASES | {
settings.METADATA_DB_NAME,
settings.WNDB_TEMPLATE_DB_NAME,
}
def _validate_project_database(name: str, *, allow_template_source: bool = False) -> None:
if not name:
raise ValueError("Project database name must not be empty")
protected = {database.casefold() for database in _protected_databases()}
is_template = name.casefold().endswith("_template")
if (
allow_template_source
and name.casefold() == settings.WNDB_TEMPLATE_DB_NAME.casefold()
):
return
if name.casefold() in protected or is_template:
raise ValueError(f"Database {name!r} is protected and cannot be managed as a project")
def list_project() -> list[str]:
@@ -16,92 +49,219 @@ def list_project() -> list[str]:
with conn.cursor(row_factory=dict_row) as cur:
for p in cur.execute(
"select datname from pg_database where datname <> all(%s) order by datname",
(_server_databases,),
(list(_protected_databases()),),
):
ps.append(p["datname"])
if not str(p["datname"]).casefold().endswith("_template"):
ps.append(p["datname"])
return ps
@contextmanager
def _database_locks(cur, *database_names: str):
"""Serialize physical database lifecycle operations across server workers."""
lock_names = sorted(set(database_names), key=str.casefold)
for database_name in lock_names:
cur.execute(
"select pg_advisory_lock(hashtextextended(%s, 0))",
(f"tjwater:wndb:{database_name}",),
)
try:
yield
finally:
for database_name in reversed(lock_names):
cur.execute(
"select pg_advisory_unlock(hashtextextended(%s, 0))",
(f"tjwater:wndb:{database_name}",),
)
@contextmanager
def _temporary_database_capacity(cur, database_name: str):
"""Serialize temporary creation and enforce a server-wide hard limit."""
if not database_name.startswith(_TEMPORARY_DATABASE_PREFIX):
yield
return
lock_name = "tjwater:wndb:temporary-database-capacity"
cur.execute("select pg_advisory_lock(hashtextextended(%s, 0))", (lock_name,))
try:
cur.execute(
"select count(*) as count from pg_database where datname like %s",
(f"{_TEMPORARY_DATABASE_PREFIX}%",),
)
row = cur.fetchone()
count = int(row["count"] if row is not None else 0)
limit = max(1, settings.WNDB_TEMP_DB_MAX_COUNT)
if count >= limit:
raise RuntimeError(
f"Temporary database limit reached ({count}/{limit}); "
"retry after an active analysis completes"
)
yield
finally:
cur.execute("select pg_advisory_unlock(hashtextextended(%s, 0))", (lock_name,))
def _database_allows_connections(cur, database_name: str) -> bool:
cur.execute(
"select datallowconn from pg_database where datname = %s",
(database_name,),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"Database {database_name!r} does not exist")
return bool(row["datallowconn"])
def _set_database_connections(cur, database_name: str, *, allowed: bool) -> None:
cur.execute(
"update pg_database set datallowconn = %s where datname = %s",
(allowed, database_name),
)
def temporary_project_name(project: str, purpose: str) -> str:
"""Return a collision-resistant physical database name for one run."""
physical_name = get_project_database_name(project)
safe_purpose = re.sub(r"[^a-z0-9_]+", "_", purpose.casefold()).strip("_")
safe_project = re.sub(r"[^a-z0-9_]+", "_", physical_name.casefold()).strip("_")
prefix = (
f"{_TEMPORARY_DATABASE_PREFIX}{safe_purpose or 'run'}_"
f"{safe_project or 'project'}"
)[:29]
return f"{prefix}_{uuid4().hex}"
@contextmanager
def temporary_project_database(project: str, purpose: str):
"""Clone one project's runnable model into an isolated temporary database."""
temporary_name = temporary_project_name(project, purpose)
try:
copy_project(get_project_template_database_name(project), temporary_name)
# Import lazily to keep physical database lifecycle independent from
# model-copy implementation details at module import time.
from .database import refresh_materialized_views_after_commit
from .model_replace import replace_project_model
replace_project_model(
temporary_name,
project,
copy_source_scada=True,
)
refresh_materialized_views_after_commit(temporary_name)
yield temporary_name
finally:
if have_project(temporary_name):
delete_project(temporary_name)
@contextmanager
def temporary_template_database(name_hint: str, purpose: str):
"""Create an empty schema-only temporary database from the fixed template."""
temporary_name = temporary_project_name(name_hint, purpose)
try:
copy_project(get_project_template_database_name(name_hint), temporary_name)
yield temporary_name
finally:
if have_project(temporary_name):
delete_project(temporary_name)
def have_project(name: str) -> bool:
database_name = get_project_database_name(name)
with admin_connection() as conn:
with conn.cursor() as cur:
cur.execute("select 1 from pg_database where datname = %s", (name,))
cur.execute("select 1 from pg_database where datname = %s", (database_name,))
return cur.fetchone() is not None
def copy_project(source: str, new: str) -> None:
physical_source = get_project_database_name(source)
physical_new = get_project_database_name(new)
_validate_project_database(physical_source, allow_template_source=True)
_validate_project_database(physical_new)
close_project_pool(source)
close_project_pool(new)
with admin_connection() as admin_conn:
with admin_conn.cursor() as cur:
cur.execute(
"update pg_database set datallowconn = false where datname = %s",
(source,),
)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity where datname = %s and pid <> pg_backend_pid()",
(source,),
)
cur.execute(
sql.SQL("create database {} with template = {}").format(
sql.Identifier(new), sql.Identifier(source)
)
)
finally:
cur.execute(
"update pg_database set datallowconn = true where datname = %s",
(source,),
)
with _temporary_database_capacity(cur, physical_new):
with _database_locks(cur, physical_source, physical_new):
source_allowed = _database_allows_connections(cur, physical_source)
if source_allowed:
_set_database_connections(
cur,
physical_source,
allowed=False,
)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(physical_source,),
)
cur.execute(
sql.SQL("create database {} with template = {}").format(
sql.Identifier(physical_new),
sql.Identifier(physical_source),
)
)
finally:
if source_allowed:
_set_database_connections(
cur,
physical_source,
allowed=True,
)
def create_project(name: str) -> None:
return copy_project("project", name)
return copy_project(get_project_template_database_name(name), name)
def delete_project(name: str) -> None:
database_name = get_project_database_name(name)
_validate_project_database(database_name)
close_project_pool(name)
with admin_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(name,),
)
cur.execute(
sql.SQL("drop database {}").format(sql.Identifier(name))
)
with _database_locks(cur, database_name):
was_allowed = _database_allows_connections(cur, database_name)
if was_allowed:
_set_database_connections(cur, database_name, allowed=False)
try:
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(database_name,),
)
cur.execute(
sql.SQL("drop database {}").format(
sql.Identifier(database_name)
)
)
except Exception:
if was_allowed:
_set_database_connections(cur, database_name, allowed=True)
raise
def clean_project(excluded: list[str] = []) -> None:
projects = list_project()
def clean_project(projects: Iterable[str]) -> None:
"""Delete only the explicitly supplied project databases."""
targets = list(dict.fromkeys(projects))
physical_targets = [get_project_database_name(project) for project in targets]
for database_name in physical_targets:
_validate_project_database(database_name)
if not targets:
return
with admin_connection() as conn:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("select current_database()").fetchone()
if row != None:
current_db = row["current_database"]
if current_db in projects:
projects.remove(current_db)
for project in projects:
if project in _server_databases or project in excluded:
continue
cur.execute(
"select pg_terminate_backend(pid) from pg_stat_activity "
"where datname = %s and pid <> pg_backend_pid()",
(project,),
)
cur.execute(
sql.SQL("drop database {}").format(sql.Identifier(project))
)
current_db = row["current_database"] if row is not None else None
if current_db in physical_targets:
raise ValueError(f"Cannot delete the current database {current_db!r}")
def open_project(name: str) -> None:
get_project_pool(name)
def is_project_open(name: str) -> bool:
return is_project_pool_open(name)
def close_project(name: str) -> None:
close_project_pool(name)
for project in targets:
delete_project(project)
+16 -54
View File
@@ -1,10 +1,5 @@
from psycopg.rows import dict_row
from ..core.database import read_all, sql_literal, try_read
from ..core.connection import project_connection
from ..core.database import read_all, sql_literal, try_read, write
from ..core.connection import project_connection
from ..model.elements import get_link_nodes
from psycopg.rows import dict_row
def sql_update_coord(node: str, x: float, y: float) -> str:
geom = f"st_setsrid(st_makepoint({sql_literal(x)}, {sql_literal(y)}), 900914)"
@@ -21,8 +16,8 @@ def sql_delete_coord(node: str) -> str:
def from_postgis_point(coord: str) -> dict[str, float]:
xy = coord.lower().removeprefix('point(').removesuffix(')').split(' ')
return { 'x': float(xy[0]), 'y': float(xy[1]) }
xy = coord.lower().removeprefix("point(").removesuffix(")").split(" ")
return {"x": float(xy[0]), "y": float(xy[1])}
def get_node_coord(name: str, node: str) -> dict[str, float]:
@@ -31,51 +26,15 @@ def get_node_coord(name: str, node: str) -> dict[str, float]:
"select st_astext(geom) as coord_geom from gis.node_geometries where node_id = %s",
(node,),
)
if row == None:
write(name, sql_insert_coord(node, 0.0, 0.0))
return {'x': 0.0, 'y': 0.0}
return from_postgis_point(row['coord_geom'])
# DingZQ 2025-01-03, get nodes in extent
# return node id list
# node_id:junction:x:y
def get_nodes_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
nodes = []
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
for obj in objs:
node_id = obj['node_id']
coord = from_postgis_point(obj['coord_geom'])
x = coord['x']
y = coord['y']
if x1 <= x <= x2 and y1 <= y <= y2:
nodes.append(f"{node_id}:junction:{x}:{y}")
return nodes
# DingZQ 2025-01-03, get links in extent
# return link id list
# link_id:pipe:node_id1:node_id2
def get_links_in_extent(name: str, x1: float, y1: float, x2: float, y2: float) -> list[str]:
node_ids = set([s.split(':')[0] for s in get_nodes_in_extent(name, x1, y1, x2, y2)])
all_link_ids = []
with project_connection(name) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("select link_id from network.pipes")
for record in cur:
all_link_ids.append(record['link_id'])
links = []
for link_id in all_link_ids:
nodes = get_link_nodes(name, link_id)
if nodes[0] in node_ids and nodes[1] in node_ids:
links.append(f"{link_id}:pipe:{nodes[0]}:{nodes[1]}")
return links
if row is None:
return {"x": 0.0, "y": 0.0}
return from_postgis_point(row["coord_geom"])
def node_has_coord(name: str, node: str) -> bool:
return try_read(
name, "select node_id from gis.node_geometries where node_id = %s", (node,)
) != None
) is not None
#--------------------------------------------------------------
@@ -94,11 +53,14 @@ def inp_in_coord(line: str) -> str:
def inp_out_coord(name: str) -> list[str]:
lines = []
objs = read_all(name, 'select node_id, st_astext(geom) as coord_geom from gis.node_geometries')
objs = read_all(
name,
"select node_id, st_astext(geom) as coord_geom from gis.node_geometries",
)
for obj in objs:
node = obj['node_id']
coord = from_postgis_point(obj['coord_geom'])
x = coord['x']
y = coord['y']
lines.append(f'{node} {x} {y}')
node = obj["node_id"]
coord = from_postgis_point(obj["coord_geom"])
x = coord["x"]
y = coord["y"]
lines.append(f"{node} {x} {y}")
return lines
+191
View File
@@ -0,0 +1,191 @@
"""Read-only projections backed by the GIS materialized-view query layer."""
from typing import Any
from ..core.database import read, read_all
def _node_coord_rows(
rows: list[Any],
) -> dict[str, dict[str, Any]]:
return {
str(row["id"]): {
"x": float(row["x"]),
"y": float(row["y"]),
"type": str(row["node_type"]),
}
for row in rows
}
def get_network_node_coords(name: str) -> dict[str, dict[str, Any]]:
"""Return every publishable node with one view-backed query."""
rows = read_all(
name,
"""
SELECT id, x, y, node_type
FROM gis.network_nodes
ORDER BY id
""",
)
return _node_coord_rows(rows)
def get_major_node_coords(
name: str, diameter: int
) -> dict[str, dict[str, Any]]:
"""Return endpoints of pipes above the requested diameter."""
rows = read_all(
name,
"""
SELECT n.id, n.x, n.y, n.node_type
FROM gis.pipes AS p
CROSS JOIN LATERAL (
VALUES (p.start_node_id), (p.end_node_id)
) AS endpoint(id)
JOIN gis.network_nodes AS n ON n.id = endpoint.id
WHERE p.diameter > %s
GROUP BY n.id, n.x, n.y, n.node_type
ORDER BY n.id
""",
(diameter,),
)
return _node_coord_rows(rows)
def get_network_link_nodes(name: str) -> list[str]:
"""Return every publishable link in the established API wire format."""
rows = read_all(
name,
"""
SELECT id, link_type, start_node_id, end_node_id
FROM gis.network_links
ORDER BY id
""",
)
return [
f"{row['id']}:{row['link_type']}:{row['start_node_id']}:{row['end_node_id']}"
for row in rows
]
def get_major_pipe_nodes(name: str, diameter: int) -> list[str]:
"""Return large pipes in the established API wire format."""
rows = read_all(
name,
"""
SELECT id, start_node_id, end_node_id
FROM gis.pipes
WHERE diameter > %s
ORDER BY id
""",
(diameter,),
)
return [
f"{row['id']}:pipe:{row['start_node_id']}:{row['end_node_id']}"
for row in rows
]
def get_topology_rows(
name: str, node_ids: list[str]
) -> tuple[list[Any], list[Any]]:
"""Load selected nodes and their internal links with two batch queries."""
if not node_ids:
return [], []
nodes = read_all(
name,
"""
SELECT n.id,
ST_X(g.geom) AS x,
ST_Y(g.geom) AS y,
n.node_type::text AS node_type
FROM network.nodes AS n
JOIN gis.node_geometries AS g ON g.node_id = n.id
WHERE n.id = ANY(%s)
ORDER BY n.id
""",
(node_ids,),
)
links = read_all(
name,
"""
SELECT l.id,
l.start_node_id,
l.end_node_id,
COALESCE(p.length, 0.0) AS length
FROM network.links AS l
LEFT JOIN network.pipes AS p ON p.link_id = l.id
WHERE l.start_node_id = ANY(%s)
AND l.end_node_id = ANY(%s)
ORDER BY l.id
""",
(node_ids, node_ids),
)
return nodes, links
def get_boundary_link_ids(name: str, node_ids: list[str]) -> list[str]:
"""Return links with exactly one endpoint inside the supplied node set."""
if not node_ids:
return []
rows = read_all(
name,
"""
SELECT id
FROM network.links
WHERE (start_node_id = ANY(%s)) <> (end_node_id = ANY(%s))
ORDER BY id
""",
(node_ids, node_ids),
)
return [str(row["id"]) for row in rows]
def get_junction_demands(
name: str, node_ids: list[str]
) -> dict[str, list[dict[str, Any]]]:
"""Read authoritative demands for selected junctions from model tables."""
if not node_ids:
return {}
rows = read_all(
name,
"""
SELECT junction_id,
sequence_no,
base_demand,
pattern_id,
category
FROM network.demands
WHERE junction_id = ANY(%s)
ORDER BY junction_id, sequence_no
""",
(node_ids,),
)
result: dict[str, list[dict[str, Any]]] = {}
for row in rows:
result.setdefault(str(row["junction_id"]), []).append(
{
"demand": float(row["base_demand"]),
"pattern": row["pattern_id"],
"category": row["category"],
}
)
return result
def sum_junction_base_demand(name: str, node_ids: list[str]) -> float:
"""Sum selected junction demand from the authoritative model table."""
if not node_ids:
return 0.0
row = read(
name,
"""
SELECT COALESCE(SUM(base_demand), 0.0) AS total_base_demand
FROM network.demands
WHERE junction_id = ANY(%s)
""",
(node_ids,),
)
return float(row["total_base_demand"])
+33 -47
View File
@@ -2,10 +2,8 @@ import platform
import math
from typing import Any
import pyclipper
from ..model.elements import get_node_links, get_link_nodes, is_pipe
from ..model.pipes import get_pipe
from ..core.database import read, try_read, read_all
from .coordinates import node_has_coord, get_node_coord
from .network_views import get_boundary_link_ids, get_topology_rows
def from_postgis_polygon(polygon: str) -> list[tuple[float, float]]:
@@ -42,21 +40,7 @@ def get_nodes_in_boundary(name: str, boundary: list[tuple[float, float]]) -> lis
def _get_links_on_boundary(name: str, nodes: list[str]) -> list[str]:
links: list[str] = []
for node in nodes:
node_links = get_node_links(name, node)
for link in node_links:
if link in links:
continue
link_nodes = get_link_nodes(name, link)
if link_nodes[0] in nodes and link_nodes[1] not in nodes:
links.append(link)
elif link_nodes[0] not in nodes and link_nodes[1] in nodes:
links.append(link)
return links
return get_boundary_link_ids(name, nodes)
def get_nodes_in_region(name: str, region_id: str) -> list[str]:
@@ -128,37 +112,39 @@ def _angle_of_node_link(node: str, link: str, nodes, links) -> float:
class Topology:
def __init__(self, db: str, nodes: list[str]) -> None:
self._nodes: dict[str, Any] = {}
self._max_x_node = ''
self._node_list: list[str] = []
for node in nodes:
if not node_has_coord(db, node):
continue
if get_node_links(db, node) == 0:
continue
self._nodes[node] = get_node_coord(db, node) | { 'links': [] }
self._node_list.append(node)
if self._max_x_node == '' or self._nodes[node]['x'] > self._nodes[self._max_x_node]['x']:
self._max_x_node = node
node_rows, link_rows = get_topology_rows(db, nodes)
self._nodes: dict[str, Any] = {
str(row["id"]): {
"x": float(row["x"]),
"y": float(row["y"]),
"type": str(row["node_type"]),
"links": [],
}
for row in node_rows
}
self._node_list = list(self._nodes)
self._max_x_node = max(
self._nodes,
key=lambda node_id: self._nodes[node_id]["x"],
default="",
)
self._links: dict[str, Any] = {}
self._link_list: list[str] = []
for node in self._nodes:
for link in get_node_links(db, node):
candidate = True
link_nodes = get_link_nodes(db, link)
for link_node in link_nodes:
if link_node not in self._nodes:
candidate = False
break
if candidate:
length = get_pipe(db, link)['length'] if is_pipe(db, link) else 0.0
self._links[link] = { 'node1' : link_nodes[0], 'node2' : link_nodes[1], 'length' : length }
self._link_list.append(link)
if link not in self._nodes[link_nodes[0]]['links']:
self._nodes[link_nodes[0]]['links'].append(link)
if link not in self._nodes[link_nodes[1]]['links']:
self._nodes[link_nodes[1]]['links'].append(link)
for row in link_rows:
link_id = str(row["id"])
node1 = str(row["start_node_id"])
node2 = str(row["end_node_id"])
if node1 not in self._nodes or node2 not in self._nodes:
continue
self._links[link_id] = {
"node1": node1,
"node2": node2,
"length": float(row["length"]),
}
self._nodes[node1]["links"].append(link_id)
self._nodes[node2]["links"].append(link_id)
self._link_list = list(self._links)
def nodes(self):
return self._nodes
+4 -21
View File
@@ -1,6 +1,6 @@
import os
from ..core.projects import close_project, have_project, is_project_open, open_project
from ..core.projects import have_project
from ..core.database import ChangeSet
from .sections import (
BACKDROP,
@@ -56,7 +56,7 @@ from ..model.reactions import inp_out_reaction
from ..model.mixing import inp_out_mixing
from ..model.times import inp_out_time
from ..model.reports import inp_out_report
from ..model.options_legacy import inp_out_option
from ..model.options_v2 import inp_out_option_v2
from ..model.options_v3 import inp_out_option_v3
from ..gis.coordinates import inp_out_coord
from ..gis.vertices import inp_out_vertex
@@ -72,11 +72,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
if not have_project(project):
return
project_open = is_project_open(project)
if not project_open:
open_project(project)
dir = os.getcwd()
path = os.path.join(dir, inp)
@@ -173,7 +168,7 @@ def dump_inp(project: str, inp: str, version: str = '3'):
if version == '3':
file.write('\n'.join(inp_out_option_v3(project)))
else:
file.write('\n'.join(inp_out_option(project)))
file.write('\n'.join(inp_out_option_v2(project)))
elif name == COORDINATES:
file.write('\n'.join(inp_out_coord(project)))
@@ -194,10 +189,6 @@ def dump_inp(project: str, inp: str, version: str = '3'):
file.close()
if not project_open:
close_project(project)
def export_inp(project: str, version: str = '3') -> ChangeSet:
if version != '3' and version != '2':
version = '2'
@@ -205,11 +196,6 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
if not have_project(project):
return ChangeSet()
project_open = is_project_open(project)
if not project_open:
open_project(project)
inp = ''
for name in section_name:
@@ -294,7 +280,7 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
if version == '3':
inp += '\n'.join(inp_out_option_v3(project))
else:
inp += '\n'.join(inp_out_option(project))
inp += '\n'.join(inp_out_option_v2(project))
elif name == COORDINATES:
inp += '\n'.join(inp_out_coord(project))
@@ -313,7 +299,4 @@ def export_inp(project: str, version: str = '3') -> ChangeSet:
inp += '\n'
if not project_open:
close_project(project)
return ChangeSet({'operation': 'export', 'inp': inp})
+69 -67
View File
@@ -1,18 +1,26 @@
import datetime
import logging
import os
from tempfile import NamedTemporaryFile
from psycopg import sql
from ..core.projects import (
close_project,
create_project,
copy_project,
delete_project,
have_project,
is_project_open,
open_project,
temporary_project_name,
temporary_template_database,
)
from app.infra.db.project_routing import get_project_template_database_name
from ..core.connection import project_transaction
from ..core.database import ChangeSet, refresh_materialized_views, sql_literal, write
from ..core.model_replace import replace_project_model
from ..core.database import (
ChangeSet,
refresh_materialized_views_after_commit,
sql_literal,
write,
)
from .sections import (
BACKDROP,
BOUND,
@@ -68,7 +76,7 @@ from ..model.reactions import inp_in_reaction
from ..model.mixing import inp_in_mixing
from ..model.times import inp_in_time
from ..model.reports import inp_in_report
from ..model.options_legacy import inp_in_option
from ..model.options_v2 import inp_in_option_v2
from ..model.options_v3 import inp_in_option_v3
from ..gis.coordinates import inp_in_coord
from ..gis.vertices import inp_in_vertex
@@ -82,10 +90,11 @@ from .exporter import export_inp
_S = "S"
_L = "L"
logger = logging.getLogger(__name__)
def _inp_in_option(section: list[str], version: str = "3") -> str:
return inp_in_option_v3(section) if version == "3" else inp_in_option(section)
return inp_in_option_v3(section) if version == "3" else inp_in_option_v2(section)
_handler = {
@@ -389,60 +398,49 @@ def read_inp(project: str, inp: str, version: str = "3") -> bool:
if version != "3" and version != "2":
version = "2"
if is_project_open(project):
close_project(project)
if not have_project(project):
raise ValueError(f"Project database {project!r} does not exist")
if have_project(project):
delete_project(project)
staging_project = temporary_project_name(project, "model_import")
replacement_committed = False
try:
copy_project(get_project_template_database_name(project), staging_project)
with project_transaction(staging_project):
parse_file(staging_project, inp, version)
replace_project_model(project, staging_project)
replacement_committed = True
finally:
try:
if have_project(staging_project):
delete_project(staging_project)
except Exception:
logger.exception(
"Failed to remove model-import staging database %s",
staging_project,
)
create_project(project)
open_project(project)
if replacement_committed:
refresh_materialized_views_after_commit(project)
with project_transaction(project):
parse_file(project, inp, version)
refresh_materialized_views(project)
"""try:
parse_file(project, inp, version)
except:
close_project(project)
delete_project(project)
return False"""
close_project(project)
return True
# DingZQ, 2024-12-28, convert v3 to v2
def convert_inp_v3_to_v2(inp: str) -> ChangeSet:
project = "v3Tov2"
if is_project_open(project):
close_project(project)
if have_project(project):
delete_project(project)
create_project(project)
open_project(project)
filename = f"inp/{project}_temp.inp"
if os.path.exists(filename):
os.remove(filename)
with open(filename, "w", encoding="utf-8") as f:
f.write(inp)
parse_file(project, filename, "3")
"""try:
parse_file(project, inp, version)
except:
close_project(project)
delete_project(project)
return False"""
return export_inp(project, "2")
temp_path: str | None = None
with temporary_template_database("conversion", "v3_to_v2") as project:
try:
with NamedTemporaryFile(
mode="w", suffix=".inp", encoding="utf-8", delete=False
) as temp_file:
temp_file.write(inp)
temp_path = temp_file.name
with project_transaction(project):
parse_file(project, temp_path, "3")
return export_inp(project, "2")
finally:
if temp_path is not None:
os.remove(temp_path)
def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
@@ -452,17 +450,21 @@ def import_inp(project: str, cs: ChangeSet, version: str = "3") -> bool:
if "inp" not in cs.operations[0]:
return False
filename = f"inp/{project}_temp.inp"
if os.path.exists(filename):
os.remove(filename)
_print_time(f'Start writing temp file "{filename}"...')
with open(filename, "w", encoding="utf-8") as f:
f.write(str(cs.operations[0]["inp"]))
_print_time(f'End writing temp file "{filename}"...')
result = read_inp(project, filename, version)
# os.remove(filename)
return result
temp_path: str | None = None
try:
with NamedTemporaryFile(
mode="w",
suffix=".inp",
prefix="tjwater_import_",
encoding="utf-8",
delete=False,
) as temp_file:
temp_file.write(str(cs.operations[0]["inp"]))
temp_path = temp_file.name
return read_inp(project, temp_path, version)
finally:
if temp_path is not None:
try:
os.remove(temp_path)
except FileNotFoundError:
pass
-13
View File
@@ -94,16 +94,3 @@ def delete_demand_by_junction(name: str, junction: str) -> ChangeSet:
if row is None:
return ChangeSet()
return ChangeSet(g_update_prefix | {'type': 'demand', 'junction': junction, 'demands': []})
def unset_demand_by_pattern(name: str, pattern: str) -> ChangeSet:
cs = ChangeSet()
rows = read_all(name, "select distinct junction_id as junction from network.demands where pattern_id = %s", (pattern,))
for row in rows:
ds = get_demand(name, row['junction'])
for d in ds['demands']:
d['pattern'] = None
cs.append(g_update_prefix | {'type': 'demand', 'junction': row['junction'], 'demands': ds['demands']})
return cs
+2 -36
View File
@@ -159,26 +159,6 @@ def get_nodes(name: str) -> list[str]:
return _get_all(name, _NODE)
def get_nodes_id_and_type(name: str) -> dict[str, str]:
rows = read_all_typed(name, "SELECT id, node_type FROM network.nodes", ())
return {row["id"]: row["node_type"] for row in rows}
def get_major_nodes(name: str, diameter: int) -> list[str]:
rows = read_all_typed(
name,
"""
SELECT DISTINCT endpoint
FROM network.links AS l
JOIN network.pipes AS p ON p.link_id = l.id
CROSS JOIN LATERAL (VALUES (l.start_node_id), (l.end_node_id)) AS e(endpoint)
WHERE p.diameter > %s
""",
(diameter,),
)
return [row["endpoint"] for row in rows]
def get_junctions(name: str) -> list[str]:
return _get_nodes_by_type(name, JUNCTION)
@@ -195,20 +175,6 @@ def get_links(name: str) -> list[str]:
return _get_all(name, _LINK)
def get_links_id_and_type(name: str) -> dict[str, str]:
rows = read_all_typed(name, "SELECT id, link_type FROM network.links", ())
return {row["id"]: row["link_type"] for row in rows}
def get_major_pipes(name: str, diameter: int) -> list[str]:
rows = read_all_typed(
name,
"SELECT link_id FROM network.pipes WHERE diameter > %s ORDER BY link_id",
(diameter,),
)
return [row["link_id"] for row in rows]
def get_pipes(name: str) -> list[str]:
return _get_links_by_type(name, PIPE)
@@ -247,10 +213,10 @@ def get_node_links(name: str, node_id: str) -> list[str]:
def get_all_node_links(name: str) -> dict[str, list[str]]:
"""Build the node adjacency map with one scan of the link table."""
"""Build the node adjacency map with one scan of the unified GIS view."""
rows = read_all_typed(
name,
"SELECT id, start_node_id, end_node_id FROM network.links ORDER BY id",
"SELECT id, start_node_id, end_node_id FROM gis.network_links ORDER BY id",
(),
)
result: dict[str, list[str]] = {}
+15 -6
View File
@@ -6,6 +6,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -94,8 +95,12 @@ class Junction(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation }
def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_junction(name, cs.operations[0]['id'])
def _set_junction(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_junction(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_junction_schema(name)
@@ -113,11 +118,15 @@ def _set_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_junction(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_junction(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_junction(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_junction(name, operation['id'])
return None if current == {} else _set_junction(name, cs, current)
return execute_locked_command(name, build_command)
def _add_junction(name: str, cs: ChangeSet) -> DatabaseCommand:
@@ -1,10 +1,8 @@
from psycopg import sql
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
from .options import get_option_schema, generate_v3
def _inp_in_option(section: list[str]) -> ChangeSet:
def _inp_in_option_v2(section: list[str]) -> ChangeSet:
if len(section) <= 0:
return ChangeSet()
@@ -34,9 +32,9 @@ def _inp_in_option(section: list[str]) -> ChangeSet:
return result
def inp_in_option(section: list[str]) -> str:
def inp_in_option_v2(section: list[str]) -> str:
sql = ''
result = _inp_in_option(section)
result = _inp_in_option_v2(section)
for op in result.operations:
for key in op.keys():
if key == 'operation' or key == 'type':
@@ -48,7 +46,7 @@ def inp_in_option(section: list[str]) -> str:
return sql
def inp_out_option(name: str) -> list[str]:
def inp_out_option_v2(name: str) -> list[str]:
lines = []
objs = read_all(name, "select key, value from network.simulation_settings where engine_version = 'legacy' order by key")
@@ -71,7 +69,7 @@ def inp_out_option(name: str) -> list[str]:
# why write this ?
if key == 'PRESSURE':
continue
# release version does not support new keys and has error message
# EPANET V2 does not support these newer keys.
if key == 'HTOL' or key == 'QTOL' or key == 'RQTOL':
continue
# ignore some weird settings for DDA
-2
View File
@@ -1,5 +1,3 @@
from psycopg import sql
from ..core.database import ChangeSet, g_update_prefix, read_all, sql_literal
from .options import get_option_schema, get_option_v3_schema, generate_v2, generate_v3
+4 -1
View File
@@ -93,7 +93,10 @@ def _delete_pattern(name: str, cs: ChangeSet) -> DatabaseCommand:
id = cs.operations[0]['id']
f_id = sql_literal(id)
statement = f"delete from network.patterns where id = {f_id};"
statement = (
f"update network.demands set pattern_id = null where pattern_id = {f_id};"
f"\ndelete from network.patterns where id = {f_id};"
)
change = g_delete_prefix | { 'type': 'pattern' } | { 'id': id }
+52 -8
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -144,9 +145,12 @@ class Pipe(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'length': self.length, 'diameter': self.diameter, 'roughness': self.roughness, 'minor_loss': self.minor_loss, 'status': self.status }
def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_pipe(name, cs.operations[0]['id'])
def _set_pipe(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_pipe(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_pipe_schema(name)
for key, value in schema.items():
@@ -154,19 +158,59 @@ def _set_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new[key] = new_dict[key]
new = Pipe(raw_new)
statement = f"update network.links set start_node_id = {new.f_node1}, end_node_id = {new.f_node2} where id = {new.f_id};"
statement += f"\nupdate network.pipes set length = {new.f_length}, diameter = {new.f_diameter}, roughness = {new.f_roughness}, minor_loss = {new.f_minor_loss}, status = {new.f_status} where link_id = {new.f_id};"
link_columns = {
'node1': ('start_node_id', new.f_node1),
'node2': ('end_node_id', new.f_node2),
}
pipe_columns = {
'length': ('length', new.f_length),
'diameter': ('diameter', new.f_diameter),
'roughness': ('roughness', new.f_roughness),
'minor_loss': ('minor_loss', new.f_minor_loss),
'status': ('status', new.f_status),
}
statements = []
link_assignments = [
f"{column} = {value}"
for field, (column, value) in link_columns.items()
if field in new_dict
]
if link_assignments:
statements.append(
f"update network.links set {', '.join(link_assignments)} where id = {new.f_id};"
)
pipe_assignments = [
f"{column} = {value}"
for field, (column, value) in pipe_columns.items()
if field in new_dict
]
if pipe_assignments:
statements.append(
f"update network.pipes set {', '.join(pipe_assignments)} where link_id = {new.f_id};"
)
statement = "\n".join(statements)
change = g_update_prefix | new.as_dict()
return DatabaseCommand(statement, [change])
def set_pipe(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_pipe(name, cs.operations[0]['id']) == {}:
mutable_fields = {
'node1', 'node2', 'length', 'diameter', 'roughness', 'minor_loss', 'status'
}
if not mutable_fields.intersection(operation):
return ChangeSet()
return execute_command(name, _set_pipe(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_pipe(name, operation['id'])
if current == {}:
return None
return _set_pipe(name, cs, current)
return execute_locked_command(name, build_command)
def _add_pipe(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -87,8 +88,12 @@ class Pump(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'power': self.power, 'head': self.head, 'speed': self.speed, 'pattern': self.pattern }
def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_pump(name, cs.operations[0]['id'])
def _set_pump(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_pump(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_pump_schema(name)
@@ -105,11 +110,15 @@ def _set_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_pump(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_pump(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_pump(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_pump(name, operation['id'])
return None if current == {} else _set_pump(name, cs, current)
return execute_locked_command(name, build_command)
def _add_pump(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -85,8 +86,12 @@ class Reservoir(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'head': self.head, 'pattern': self.pattern }
def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_reservoir(name, cs.operations[0]['id'])
def _set_reservoir(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_reservoir(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_reservoir_schema(name)
@@ -104,11 +109,15 @@ def _set_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_reservoir(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_reservoir(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_reservoir(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_reservoir(name, operation['id'])
return None if current == {} else _set_reservoir(name, cs, current)
return execute_locked_command(name, build_command)
def _add_reservoir(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -121,8 +122,12 @@ class Tank(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'x': self.x, 'y': self.y, 'elevation': self.elevation, 'init_level': self.init_level, 'min_level': self.min_level, 'max_level': self.max_level, 'diameter': self.diameter, 'min_vol': self.min_vol, 'vol_curve': self.vol_curve, 'overflow': self.overflow }
def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_tank(name, cs.operations[0]['id'])
def _set_tank(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_tank(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_tank_schema(name)
@@ -140,11 +145,15 @@ def _set_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_tank(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_tank(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_tank(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_tank(name, operation['id'])
return None if current == {} else _set_tank(name, cs, current)
return execute_locked_command(name, build_command)
def _add_tank(name: str, cs: ChangeSet) -> DatabaseCommand:
+15 -6
View File
@@ -4,6 +4,7 @@ from ..core.database import (
ChangeSet,
DatabaseCommand,
execute_command,
execute_locked_command,
g_add_prefix,
g_delete_prefix,
g_update_prefix,
@@ -94,8 +95,12 @@ class Valve(object):
def as_dict(self) -> dict[str, Any]:
return { 'type': self.type, 'id': self.id, 'node1': self.node1, 'node2': self.node2, 'diameter': self.diameter, 'v_type': self.v_type, 'setting': self.setting, 'minor_loss': self.minor_loss }
def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
raw_new = get_valve(name, cs.operations[0]['id'])
def _set_valve(
name: str,
cs: ChangeSet,
current: dict[str, Any] | None = None,
) -> DatabaseCommand:
raw_new = current if current is not None else get_valve(name, cs.operations[0]['id'])
new_dict = cs.operations[0]
schema = get_valve_schema(name)
@@ -112,11 +117,15 @@ def _set_valve(name: str, cs: ChangeSet) -> DatabaseCommand:
def set_valve(name: str, cs: ChangeSet) -> ChangeSet:
if 'id' not in cs.operations[0]:
operation = cs.operations[0]
if 'id' not in operation:
return ChangeSet()
if get_valve(name, cs.operations[0]['id']) == {}:
return ChangeSet()
return execute_command(name, _set_valve(name, cs))
def build_command() -> DatabaseCommand | None:
current = get_valve(name, operation['id'])
return None if current == {} else _set_valve(name, cs, current)
return execute_locked_command(name, build_command)
def _add_valve(name: str, cs: ChangeSet) -> DatabaseCommand: