feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
"""Import the two Fengxian GIS deliveries into one project BizDB.
|
||||
|
||||
The source PRJ files declare EPSG:21481, while their coordinate values match the
|
||||
project's Shanghai local CRS (SRID 900914). The importer records both facts and
|
||||
assigns the validated local CRS without changing coordinate values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from psycopg import sql
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.infra.db.project_routing import get_project_pgconn_string
|
||||
from app.native.wndb.core.connection import project_connection, project_transaction
|
||||
|
||||
|
||||
SCHEMA_SQL = PROJECT_ROOT / "resources/sql/010_source_gis_inventory.sql"
|
||||
DEFAULT_APRIL_ROOT = Path(
|
||||
"/mnt/c/Users/admin/Desktop/data/奉贤/20200423全网除头桥数据导出"
|
||||
)
|
||||
DEFAULT_AUGUST_ROOT = Path(
|
||||
"/mnt/c/Users/admin/Desktop/data/奉贤/20200811奉贤100口径以上(含台账)(1)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatasetSpec:
|
||||
key: str
|
||||
title: str
|
||||
source_date: date
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerSpec:
|
||||
key: str
|
||||
dataset: DatasetSpec
|
||||
display_name: str
|
||||
source_file: Path
|
||||
feature_kind: str
|
||||
asset_id_candidates: tuple[str, ...] = ()
|
||||
|
||||
|
||||
APRIL_DATASET = DatasetSpec(
|
||||
key="fx_water_assets_20200423",
|
||||
title="奉贤全网除头桥 GIS 数据导出",
|
||||
source_date=date(2020, 4, 23),
|
||||
)
|
||||
AUGUST_DATASET = DatasetSpec(
|
||||
key="fx_pipe_inventory_20200811",
|
||||
title="奉贤 DN100 及以上管网台账",
|
||||
source_date=date(2020, 8, 11),
|
||||
)
|
||||
|
||||
|
||||
def layer_specs(april_root: Path, august_root: Path) -> tuple[LayerSpec, ...]:
|
||||
return (
|
||||
LayerSpec("fx_20200423_drain_valves", APRIL_DATASET, "排水阀", april_root / "排水阀.shp", "drain_valve", ("排水阀ID编", "排水阀ID编码")),
|
||||
LayerSpec("fx_20200423_service_connections", APRIL_DATASET, "接水点", april_root / "接水点.shp", "service_connection", ("接水点ID编", "接水点ID编码")),
|
||||
LayerSpec("fx_20200423_flow_meters", APRIL_DATASET, "流量仪", april_root / "流量仪.shp", "flow_meter", ("流量仪ID编", "流量仪ID编码")),
|
||||
LayerSpec("fx_20200423_hydrants", APRIL_DATASET, "消火栓", april_root / "消火栓.shp", "hydrant", ("消火栓ID编", "消火栓ID编码")),
|
||||
LayerSpec("fx_20200423_customer_meters", APRIL_DATASET, "用户水表", april_root / "用户水表.shp", "customer_meter", ("水表ID编码", "用户水表ID")),
|
||||
LayerSpec("fx_20200423_pipes", APRIL_DATASET, "管线", april_root / "管线.shp", "pipe", ("管线ID编码",)),
|
||||
LayerSpec("fx_20200423_valves", APRIL_DATASET, "阀门", april_root / "阀门.shp", "valve", ("阀门ID编码",)),
|
||||
LayerSpec("fx_20200423_non_residential_meters", APRIL_DATASET, "非居民用户水表", april_root / "非居民用户水表.shp", "non_residential_meter", ("水表ID编码", "用户水表ID")),
|
||||
LayerSpec(
|
||||
"fx_20200811_dn100_pipes",
|
||||
AUGUST_DATASET,
|
||||
"DN100 及以上管线台账",
|
||||
august_root / "20200811奉贤管网100及以上(含台账).shp",
|
||||
"pipe",
|
||||
("PIPE_ID",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _postgres_environment(database_name: str) -> dict[str, str]:
|
||||
parts = conninfo_to_dict(get_project_pgconn_string(db_name=database_name))
|
||||
env = os.environ.copy()
|
||||
for source, target in (
|
||||
("host", "PGHOST"),
|
||||
("port", "PGPORT"),
|
||||
("user", "PGUSER"),
|
||||
("password", "PGPASSWORD"),
|
||||
("dbname", "PGDATABASE"),
|
||||
):
|
||||
value = parts.get(source)
|
||||
if value:
|
||||
env[target] = value
|
||||
return env
|
||||
|
||||
|
||||
def _load_staging_table(spec: LayerSpec, database_name: str) -> str:
|
||||
stage_name = f"_stage_{spec.key}"
|
||||
with project_connection(database_name) as conn:
|
||||
conn.execute(
|
||||
sql.SQL("DROP TABLE IF EXISTS {}.{} CASCADE").format(
|
||||
sql.Identifier("source_gis"), sql.Identifier(stage_name)
|
||||
)
|
||||
)
|
||||
command = [
|
||||
"ogr2ogr",
|
||||
"--config",
|
||||
"PG_USE_COPY",
|
||||
"YES",
|
||||
"--config",
|
||||
"OSR_USE_NON_DEPRECATED",
|
||||
"NO",
|
||||
"-f",
|
||||
"PostgreSQL",
|
||||
"PG:",
|
||||
str(spec.source_file),
|
||||
"-overwrite",
|
||||
"-preserve_fid",
|
||||
"-dim",
|
||||
"XY",
|
||||
"-mapFieldType",
|
||||
"Date=String",
|
||||
"-nln",
|
||||
stage_name,
|
||||
"-lco",
|
||||
"SCHEMA=source_gis",
|
||||
"-lco",
|
||||
"FID=source_fid",
|
||||
"-lco",
|
||||
"GEOMETRY_NAME=geom",
|
||||
"-lco",
|
||||
"LAUNDER=NO",
|
||||
]
|
||||
subprocess.run(
|
||||
command,
|
||||
env=_postgres_environment(database_name),
|
||||
check=True,
|
||||
text=True,
|
||||
)
|
||||
return stage_name
|
||||
|
||||
|
||||
def _first_column(columns: set[str], candidates: tuple[str, ...]) -> str | None:
|
||||
return next((candidate for candidate in candidates if candidate in columns), None)
|
||||
|
||||
|
||||
def _column_as_text(column: str | None) -> sql.Composable:
|
||||
if column is None:
|
||||
return sql.SQL("NULL::text")
|
||||
return sql.SQL("NULLIF(BTRIM({}::text), '')").format(sql.Identifier(column))
|
||||
|
||||
|
||||
def _column_as_float(column: str | None) -> sql.Composable:
|
||||
if column is None:
|
||||
return sql.SQL("NULL::double precision")
|
||||
return sql.SQL("{}::double precision").format(sql.Identifier(column))
|
||||
|
||||
|
||||
def _store_layer(database_name: str, spec: LayerSpec, stage_name: str) -> int:
|
||||
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attname
|
||||
FROM pg_attribute a
|
||||
WHERE a.attrelid = to_regclass(%s)
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
""",
|
||||
(f"source_gis.{stage_name}",),
|
||||
)
|
||||
columns = {row["attname"] for row in cur.fetchall()}
|
||||
if not {"source_fid", "geom"}.issubset(columns):
|
||||
raise RuntimeError(f"Staging table for {spec.key} is incomplete")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO source_gis.datasets (
|
||||
dataset_key, title, source_date, declared_srid, effective_srid,
|
||||
coordinate_note, imported_at
|
||||
) VALUES (%s, %s, %s, 21481, 900914, %s, NOW())
|
||||
ON CONFLICT (dataset_key) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
source_date = EXCLUDED.source_date,
|
||||
declared_srid = EXCLUDED.declared_srid,
|
||||
effective_srid = EXCLUDED.effective_srid,
|
||||
coordinate_note = EXCLUDED.coordinate_note,
|
||||
imported_at = NOW()
|
||||
""",
|
||||
(
|
||||
spec.dataset.key,
|
||||
spec.dataset.title,
|
||||
spec.dataset.source_date,
|
||||
"源 PRJ 声明 EPSG:21481,但坐标值与项目 SRID 900914 一致;原坐标未重投影。",
|
||||
),
|
||||
)
|
||||
cur.execute(
|
||||
sql.SQL("SELECT COUNT(*) AS count, GeometryType(geom) AS geometry_type FROM {}.{} GROUP BY GeometryType(geom)").format(
|
||||
sql.Identifier("source_gis"), sql.Identifier(stage_name)
|
||||
)
|
||||
)
|
||||
geometry_rows = cur.fetchall()
|
||||
count = sum(int(row["count"]) for row in geometry_rows)
|
||||
geometry_type = ",".join(sorted({row["geometry_type"] for row in geometry_rows}))
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO source_gis.layers (
|
||||
layer_key, dataset_key, display_name, source_file, feature_kind,
|
||||
geometry_type, feature_count, imported_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (layer_key) DO UPDATE SET
|
||||
dataset_key = EXCLUDED.dataset_key,
|
||||
display_name = EXCLUDED.display_name,
|
||||
source_file = EXCLUDED.source_file,
|
||||
feature_kind = EXCLUDED.feature_kind,
|
||||
geometry_type = EXCLUDED.geometry_type,
|
||||
feature_count = EXCLUDED.feature_count,
|
||||
imported_at = NOW()
|
||||
""",
|
||||
(
|
||||
spec.key,
|
||||
spec.dataset.key,
|
||||
spec.display_name,
|
||||
spec.source_file.name,
|
||||
spec.feature_kind,
|
||||
geometry_type,
|
||||
count,
|
||||
),
|
||||
)
|
||||
cur.execute("DELETE FROM source_gis.features WHERE layer_key = %s", (spec.key,))
|
||||
|
||||
object_id = _first_column(columns, ("OBJECTID", "ObjectID", "objectid"))
|
||||
keyword = _first_column(columns, ("GJZ", "关键字"))
|
||||
asset_id = _first_column(columns, spec.asset_id_candidates)
|
||||
diameter = _first_column(columns, ("PIPE_D", "DIAMETER", "直径"))
|
||||
from_node = _first_column(columns, ("FNODE", "起点编号", "起点ID"))
|
||||
to_node = _first_column(columns, ("TNODE", "终点编号", "终点ID"))
|
||||
insert_query = sql.SQL(
|
||||
"""
|
||||
INSERT INTO source_gis.features (
|
||||
layer_key, source_fid, source_object_id, source_keyword,
|
||||
source_asset_id, source_diameter, from_node_id, to_node_id,
|
||||
attributes, geom
|
||||
)
|
||||
SELECT %s, source_fid, {}, {}, {}, {}, {}, {},
|
||||
to_jsonb(stage) - 'source_fid' - 'geom',
|
||||
ST_Force2D(ST_SetSRID(geom, 900914))
|
||||
FROM {}.{} AS stage
|
||||
"""
|
||||
).format(
|
||||
_column_as_text(object_id),
|
||||
_column_as_text(keyword),
|
||||
_column_as_text(asset_id),
|
||||
_column_as_float(diameter),
|
||||
_column_as_text(from_node),
|
||||
_column_as_text(to_node),
|
||||
sql.Identifier("source_gis"),
|
||||
sql.Identifier(stage_name),
|
||||
)
|
||||
cur.execute(insert_query, (spec.key,))
|
||||
cur.execute(
|
||||
sql.SQL("DROP TABLE {}.{}").format(
|
||||
sql.Identifier("source_gis"), sql.Identifier(stage_name)
|
||||
)
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def _rebuild_element_links(database_name: str) -> dict[str, int]:
|
||||
with project_connection(database_name) as conn, conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE source_gis.features AS april
|
||||
SET source_diameter = august.source_diameter
|
||||
FROM source_gis.features AS august
|
||||
WHERE april.layer_key = 'fx_20200423_pipes'
|
||||
AND august.layer_key = 'fx_20200811_dn100_pipes'
|
||||
AND april.source_object_id = august.source_object_id
|
||||
AND ST_Equals(april.geom, august.geom)
|
||||
AND april.source_diameter IS NULL
|
||||
"""
|
||||
)
|
||||
cur.execute("DELETE FROM source_gis.element_links")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO source_gis.element_links (
|
||||
element_type, element_id, layer_key, source_fid,
|
||||
match_method, distance_m
|
||||
)
|
||||
SELECT 'pipes', pipe.id, layer.layer_key, match.source_fid,
|
||||
'node_pair', 0
|
||||
FROM gis.pipes AS pipe
|
||||
CROSS JOIN source_gis.layers AS layer
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT feature.source_fid
|
||||
FROM source_gis.features AS feature
|
||||
WHERE feature.layer_key = layer.layer_key
|
||||
AND (
|
||||
(feature.from_node_id = pipe.start_node_id AND feature.to_node_id = pipe.end_node_id)
|
||||
OR
|
||||
(feature.from_node_id = pipe.end_node_id AND feature.to_node_id = pipe.start_node_id)
|
||||
)
|
||||
ORDER BY feature.source_fid
|
||||
LIMIT 1
|
||||
) AS match
|
||||
WHERE layer.feature_kind = 'pipe'
|
||||
"""
|
||||
)
|
||||
node_pair_count = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO source_gis.element_links (
|
||||
element_type, element_id, layer_key, source_fid,
|
||||
match_method, distance_m
|
||||
)
|
||||
SELECT 'pipes', model.element_id, model.layer_key, match.source_fid,
|
||||
'geometry', match.distance_m
|
||||
FROM (
|
||||
SELECT pipe.id AS element_id, layer.layer_key,
|
||||
pipe.diameter,
|
||||
ST_Transform(pipe.geom, 900914) AS geom
|
||||
FROM gis.pipes AS pipe
|
||||
CROSS JOIN source_gis.layers AS layer
|
||||
WHERE layer.feature_kind = 'pipe'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM source_gis.element_links AS existing
|
||||
WHERE existing.element_type = 'pipes'
|
||||
AND existing.element_id = pipe.id
|
||||
AND existing.layer_key = layer.layer_key
|
||||
)
|
||||
AND ST_Length(ST_Transform(pipe.geom, 900914)) >= 0.1
|
||||
) AS model
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT candidate.source_fid, candidate.distance_m
|
||||
FROM (
|
||||
SELECT feature.source_fid,
|
||||
ST_HausdorffDistance(feature.geom, model.geom) AS distance_m
|
||||
FROM source_gis.features AS feature
|
||||
WHERE feature.layer_key = model.layer_key
|
||||
AND feature.geom && ST_Expand(model.geom, 1.0)
|
||||
AND (
|
||||
feature.source_diameter IS NULL
|
||||
OR model.diameter IS NULL
|
||||
OR ABS(feature.source_diameter - model.diameter)
|
||||
<= GREATEST(1.0, model.diameter * 0.02)
|
||||
)
|
||||
ORDER BY feature.geom <-> model.geom
|
||||
LIMIT 8
|
||||
) AS candidate
|
||||
ORDER BY candidate.distance_m, candidate.source_fid
|
||||
LIMIT 1
|
||||
) AS match
|
||||
WHERE match.distance_m <= 1.0
|
||||
ON CONFLICT (element_type, element_id, layer_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
geometry_count = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
WITH point_matches AS (
|
||||
SELECT feature.layer_key, feature.source_fid,
|
||||
nearest.node_id AS element_id,
|
||||
nearest.distance_m,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY nearest.node_id, feature.layer_key
|
||||
ORDER BY nearest.distance_m, feature.source_fid
|
||||
) AS match_rank
|
||||
FROM source_gis.features AS feature
|
||||
JOIN source_gis.layers AS layer
|
||||
ON layer.layer_key = feature.layer_key
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT node.node_id,
|
||||
ST_Distance(node.geom, feature.geom) AS distance_m
|
||||
FROM gis.node_geometries AS node
|
||||
ORDER BY node.geom <-> feature.geom
|
||||
LIMIT 1
|
||||
) AS nearest
|
||||
JOIN gis.junctions AS junction
|
||||
ON junction.id = nearest.node_id
|
||||
WHERE layer.feature_kind <> 'pipe'
|
||||
AND nearest.distance_m <= 0.1
|
||||
)
|
||||
INSERT INTO source_gis.element_links (
|
||||
element_type, element_id, layer_key, source_fid,
|
||||
match_method, distance_m
|
||||
)
|
||||
SELECT 'junctions', element_id, layer_key, source_fid,
|
||||
'geometry', distance_m
|
||||
FROM point_matches
|
||||
WHERE match_rank = 1
|
||||
"""
|
||||
)
|
||||
junction_count = cur.rowcount
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO source_gis.element_links (
|
||||
element_type, element_id, layer_key, source_fid,
|
||||
match_method, distance_m
|
||||
)
|
||||
SELECT 'valves', valve.id, layer.layer_key, match.source_fid,
|
||||
'geometry', match.distance_m
|
||||
FROM gis.valves AS valve
|
||||
CROSS JOIN source_gis.layers AS layer
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT feature.source_fid,
|
||||
ST_Distance(feature.geom, ST_Transform(valve.geom, 900914)) AS distance_m
|
||||
FROM source_gis.features AS feature
|
||||
WHERE feature.layer_key = layer.layer_key
|
||||
ORDER BY feature.geom <-> ST_Transform(valve.geom, 900914)
|
||||
LIMIT 1
|
||||
) AS match
|
||||
WHERE layer.feature_kind = 'valve'
|
||||
AND match.distance_m <= 2.0
|
||||
"""
|
||||
)
|
||||
valve_count = cur.rowcount
|
||||
return {
|
||||
"node_pair": node_pair_count,
|
||||
"pipe_geometry": geometry_count,
|
||||
"junction_geometry": junction_count,
|
||||
"valve_geometry": valve_count,
|
||||
}
|
||||
|
||||
|
||||
def import_sources(database_name: str, specs: tuple[LayerSpec, ...]) -> None:
|
||||
missing = [str(spec.source_file) for spec in specs if not spec.source_file.is_file()]
|
||||
if missing:
|
||||
raise FileNotFoundError("Missing GIS source files: " + ", ".join(missing))
|
||||
with project_connection(database_name) as conn:
|
||||
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
|
||||
|
||||
staged_layers: list[tuple[LayerSpec, str]] = []
|
||||
for spec in specs:
|
||||
print(f"Loading {spec.display_name} ({spec.key})")
|
||||
staged_layers.append((spec, _load_staging_table(spec, database_name)))
|
||||
|
||||
total = 0
|
||||
with project_transaction(database_name) as conn:
|
||||
conn.execute(
|
||||
"TRUNCATE TABLE source_gis.element_links, source_gis.features"
|
||||
)
|
||||
for spec, stage_name in staged_layers:
|
||||
count = _store_layer(database_name, spec, stage_name)
|
||||
total += count
|
||||
print(f" stored {count:,} features")
|
||||
links = _rebuild_element_links(database_name)
|
||||
print(f"Imported {total:,} features into {database_name}")
|
||||
print(
|
||||
"Element links: "
|
||||
f"{links['node_pair']:,} node-pair, "
|
||||
f"{links['pipe_geometry']:,} pipe geometry, "
|
||||
f"{links['junction_geometry']:,} junction geometry, "
|
||||
f"{links['valve_geometry']:,} valve geometry"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", default="tjwater_v2")
|
||||
parser.add_argument("--april-root", type=Path, default=DEFAULT_APRIL_ROOT)
|
||||
parser.add_argument("--august-root", type=Path, default=DEFAULT_AUGUST_ROOT)
|
||||
args = parser.parse_args()
|
||||
import_sources(args.database, layer_specs(args.april_root, args.august_root))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user