from __future__ import annotations import argparse import datetime as dt import math import sqlite3 import subprocess from pathlib import Path from shapely.affinity import affine_transform from shapely.geometry import box from shapely.ops import unary_union from shapely.prepared import prep from shapely.wkt import loads as load_wkt DB_NAME = "pbf_analysis" OUT_DB = Path("out/navsea_kyushu_navigability_test.sqlite") OUT_REPORT = Path("report/navsea_kyushu_navigability_test_2026-04-22.md") MYSQL_BASE = [ "mysql", "-h", "localhost", "-u", "root", "-p2chi9ks2", "-D", DB_NAME, "-B", "-N", "--raw", "--default-character-set=utf8mb4", ] WORLD_SPAN_M = 40075016.68557849 ORIGIN_SHIFT_M = WORLD_SPAN_M / 2.0 TARGET_Z = 12 TILE_EXTENT = 1048576.0 TILE_SPAN_M = WORLD_SPAN_M / (2**TARGET_Z) LOCAL_TO_M = TILE_SPAN_M / TILE_EXTENT PORT_AOI_RADIUS_M = 1000.0 COLLISION_BUFFER_M = 80.0 GROUNDING_BUFFER_M = 40.0 BOUNDARY_BUFFER_M = 30.0 ROUTE_BUFFER_M = 25.0 GRID_200_M = 200.0 GRID_50_M = 50.0 KEY_SHIFT = 20 KEY_MASK = (1 << KEY_SHIFT) - 1 PORT_TRIGGER_TYPES = ( "漁港", "一般港湾 (港則法区域、重要港湾)", "小港湾 (避難港、地方港湾)", ) TILE_COMMIT_INTERVAL = 100 GRID_PROFILE_PORT_ONLY = "port-tiles" GRID_PROFILE_FULL_DOMAIN = "full-domain" DEFAULT_COMMIT_INTERVAL = 20 def meta_upsert(conn: sqlite3.Connection, key: str, value: str) -> None: conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", (key, value)) def meta_get(conn: sqlite3.Connection, key: str, default: str = "") -> str: row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() return str(row[0]) if row else default def table_exists(conn: sqlite3.Connection, table: str) -> bool: row = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1", (table,), ).fetchone() return row is not None def table_count(conn: sqlite3.Connection, table: str) -> int: if not table_exists(conn, table): return 0 return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) def mysql_stream(query: str): proc = subprocess.Popen( MYSQL_BASE + ["-e", query], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, ) assert proc.stdout is not None assert proc.stderr is not None try: for raw_line in proc.stdout: line = raw_line.rstrip("\n") if not line: continue yield line.split("\t") finally: rc = proc.wait() if rc != 0: raise RuntimeError(proc.stderr.read().strip()) def encode_key(col: int, row: int) -> int: return (col << KEY_SHIFT) | row def decode_key(key: int) -> tuple[int, int]: return key >> KEY_SHIFT, key & KEY_MASK def mercator_tile_affine(tile_x: int, tile_y: int) -> list[float]: xoff = -ORIGIN_SHIFT_M + tile_x * TILE_SPAN_M yoff = ORIGIN_SHIFT_M - tile_y * TILE_SPAN_M return [LOCAL_TO_M, 0.0, 0.0, -LOCAL_TO_M, xoff, yoff] def mercator_point_to_tile(mx: float, my: float, z: int = TARGET_Z) -> tuple[int, int]: tiles = 2**z tx = int(math.floor(((mx + ORIGIN_SHIFT_M) / WORLD_SPAN_M) * tiles)) ty = int(math.floor(((ORIGIN_SHIFT_M - my) / WORLD_SPAN_M) * tiles)) return max(0, min(tiles - 1, tx)), max(0, min(tiles - 1, ty)) def cell_index_range(minx: float, miny: float, maxx: float, maxy: float, grid_size: float) -> tuple[int, int, int, int]: col_min = int(math.floor((minx + ORIGIN_SHIFT_M) / grid_size)) col_max = int(math.floor(((maxx + ORIGIN_SHIFT_M) - 1e-9) / grid_size)) row_min = int(math.floor((ORIGIN_SHIFT_M - maxy) / grid_size)) row_max = int(math.floor(((ORIGIN_SHIFT_M - miny) - 1e-9) / grid_size)) return col_min, col_max, row_min, row_max def cell_bounds(col: int, row: int, grid_size: float) -> tuple[float, float, float, float]: minx = -ORIGIN_SHIFT_M + col * grid_size maxx = minx + grid_size maxy = ORIGIN_SHIFT_M - row * grid_size miny = maxy - grid_size return minx, miny, maxx, maxy def make_cell_row( grid_level: int, col: int, row: int, state: str, blocked: int, reason: str | None, risk_score: int, confidence: str, counts: list[int] | None = None, ) -> tuple: counts = counts or [0, 0, 0, 0, 0, 0] minx, miny, maxx, maxy = cell_bounds(col, row, float(grid_level)) parent_col = col // 4 if grid_level == 50 else None parent_row = row // 4 if grid_level == 50 else None return ( grid_level, col, row, parent_col, parent_row, minx, miny, maxx, maxy, state, blocked, reason, risk_score, confidence, counts[0], counts[1], counts[2], counts[3], counts[4], counts[5], ) def classify_cell(counts: list[int]) -> tuple[str, int, str | None, int]: land_hits, hole_hits, collision_hits, grounding_hits, route_hits, boundary_hits = counts if land_hits > hole_hits and land_hits > 0: return "blocked", 1, "land", 100 if collision_hits > 0: return "blocked", 1, "collision", 90 if grounding_hits > 0: return "caution", 0, "grounding", 60 if boundary_hits > 0: return "caution", 0, "boundary", 40 if route_hits > 0: return "preferred", 0, "route", 20 return "open_water", 0, None, 0 def create_schema(conn: sqlite3.Connection) -> None: if table_exists(conn, "meta"): return conn.executescript( """ CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE tile_default ( z INTEGER NOT NULL, x INTEGER NOT NULL, y INTEGER NOT NULL, default_state TEXT NOT NULL, has_port_refine INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (z, x, y) ); CREATE TABLE port_aoi ( aoi_id INTEGER PRIMARY KEY AUTOINCREMENT, feature_id INTEGER NOT NULL, fid TEXT, canonical_object_type TEXT NOT NULL, center_x REAL NOT NULL, center_y REAL NOT NULL, radius_m REAL NOT NULL ); CREATE TABLE grid_cell ( grid_level INTEGER NOT NULL, grid_col INTEGER NOT NULL, grid_row INTEGER NOT NULL, parent_grid_col INTEGER, parent_grid_row INTEGER, minx REAL NOT NULL, miny REAL NOT NULL, maxx REAL NOT NULL, maxy REAL NOT NULL, state TEXT NOT NULL, blocked INTEGER NOT NULL DEFAULT 0, state_reason TEXT, risk_score INTEGER NOT NULL DEFAULT 0, confidence TEXT NOT NULL, land_hits INTEGER NOT NULL DEFAULT 0, hole_hits INTEGER NOT NULL DEFAULT 0, collision_hits INTEGER NOT NULL DEFAULT 0, grounding_hits INTEGER NOT NULL DEFAULT 0, route_hits INTEGER NOT NULL DEFAULT 0, boundary_hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (grid_level, grid_col, grid_row) ); CREATE INDEX idx_grid_cell_state ON grid_cell(grid_level, state, blocked); CREATE INDEX idx_grid_cell_parent ON grid_cell(parent_grid_col, parent_grid_row); """ ) conn.executemany( "INSERT INTO meta(key, value) VALUES (?, ?)", [ ("source_db", DB_NAME), ("source_scope", "九州"), ("build_time", dt.datetime.now().isoformat(timespec="seconds")), ("target_zoom", str(TARGET_Z)), ("tile_extent", str(int(TILE_EXTENT))), ("grid_profile", GRID_PROFILE_FULL_DOMAIN), ("port_aoi_radius_m", str(int(PORT_AOI_RADIUS_M))), ("collision_buffer_m", str(int(COLLISION_BUFFER_M))), ("grounding_buffer_m", str(int(GROUNDING_BUFFER_M))), ("boundary_buffer_m", str(int(BOUNDARY_BUFFER_M))), ("route_buffer_m", str(int(ROUTE_BUFFER_M))), ("notes", "测试版;支持九州全域 200m 主层和港口 50m 细化"), ], ) conn.commit() def load_tile_defaults(conn: sqlite3.Connection) -> int: existing = table_count(conn, "tile_default") if existing > 0: return existing batch: list[tuple[int, int, int, str, int]] = [] count = 0 for cols in mysql_stream( f""" SELECT {TARGET_Z} AS z, x, y FROM feature_geojson_lookup WHERE z = {TARGET_Z} GROUP BY x, y ORDER BY x, y """ ): z, x, y = map(int, cols) batch.append((z, x, y, "open_water", 0)) count += 1 if len(batch) >= 5000: conn.executemany( """ INSERT INTO tile_default(z, x, y, default_state, has_port_refine) VALUES (?, ?, ?, ?, ?) """, batch, ) conn.commit() batch.clear() if batch: conn.executemany( """ INSERT INTO tile_default(z, x, y, default_state, has_port_refine) VALUES (?, ?, ?, ?, ?) """, batch, ) conn.commit() return count def build_port_aoi(conn: sqlite3.Connection) -> tuple[int, int, set[int], set[tuple[int, int]]]: existing_features = table_count(conn, "port_aoi") existing_cells = conn.execute("SELECT COUNT(*) FROM grid_cell WHERE grid_level = 50").fetchone()[0] if table_exists(conn, "grid_cell") else 0 existing_tiles = { (int(x), int(y)) for x, y in conn.execute( f"SELECT x, y FROM tile_default WHERE z = {TARGET_Z} AND has_port_refine = 1" ).fetchall() } if existing_features > 0 and existing_cells > 0 and existing_tiles: port_cell_keys = { encode_key(int(col), int(row)) for col, row in conn.execute( "SELECT grid_col, grid_row FROM grid_cell WHERE grid_level = 50" ).fetchall() } return existing_features, existing_cells, port_cell_keys, existing_tiles placeholders = ",".join(f"'{value}'" for value in PORT_TRIGGER_TYPES) query = f""" SELECT d.feature_id, gl.fid, d.canonical_object_type, d.x, d.y, g.geometry_wkt FROM navsea_detection_objects d JOIN feature_geojson_lookup gl ON gl.feature_id = d.feature_id JOIN feature_geometry_stage g ON g.feature_id = d.feature_id WHERE d.z = {TARGET_Z} AND d.canonical_object_type IN ({placeholders}) ORDER BY d.x, d.y, d.feature_id """ inserted_cells = 0 port_feature_count = 0 port_cell_keys: set[int] = set() port_tiles: set[tuple[int, int]] = set() cell_batch: list[tuple] = [] aoi_batch: list[tuple] = [] for cols in mysql_stream(query): if len(cols) != 6: raise RuntimeError(f"unexpected port trigger column count: {len(cols)}") feature_id, fid, canonical_type, tile_x, tile_y, geometry_wkt = cols tile_x_i = int(tile_x) tile_y_i = int(tile_y) raw_geom = load_wkt(geometry_wkt) mercator_geom = affine_transform(raw_geom, mercator_tile_affine(tile_x_i, tile_y_i)) center = mercator_geom.centroid center_x = float(center.x) center_y = float(center.y) aoi_batch.append((int(feature_id), fid or None, canonical_type, center_x, center_y, PORT_AOI_RADIUS_M)) port_feature_count += 1 minx = center_x - PORT_AOI_RADIUS_M miny = center_y - PORT_AOI_RADIUS_M maxx = center_x + PORT_AOI_RADIUS_M maxy = center_y + PORT_AOI_RADIUS_M col_min, col_max, row_min, row_max = cell_index_range(minx, miny, maxx, maxy, GRID_50_M) tile_min = mercator_point_to_tile(minx, maxy) tile_max = mercator_point_to_tile(maxx, miny) for tx in range(tile_min[0], tile_max[0] + 1): for ty in range(tile_min[1], tile_max[1] + 1): port_tiles.add((tx, ty)) for col in range(col_min, col_max + 1): for row in range(row_min, row_max + 1): key = encode_key(col, row) if key in port_cell_keys: continue cminx, cminy, cmaxx, cmaxy = cell_bounds(col, row, GRID_50_M) center_cx = (cminx + cmaxx) / 2.0 center_cy = (cminy + cmaxy) / 2.0 if math.hypot(center_cx - center_x, center_cy - center_y) > PORT_AOI_RADIUS_M: continue port_cell_keys.add(key) inserted_cells += 1 cell_batch.append( make_cell_row( 50, col, row, "open_water", 0, None, 0, "port_aoi_default", ) ) if len(cell_batch) >= 10000: conn.executemany( """ INSERT OR IGNORE INTO grid_cell( grid_level, grid_col, grid_row, parent_grid_col, parent_grid_row, minx, miny, maxx, maxy, state, blocked, state_reason, risk_score, confidence, land_hits, hole_hits, collision_hits, grounding_hits, route_hits, boundary_hits ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, cell_batch, ) conn.commit() cell_batch.clear() if len(aoi_batch) >= 2000: conn.executemany( """ INSERT INTO port_aoi( feature_id, fid, canonical_object_type, center_x, center_y, radius_m ) VALUES (?, ?, ?, ?, ?, ?) """, aoi_batch, ) conn.commit() aoi_batch.clear() if cell_batch: conn.executemany( """ INSERT OR IGNORE INTO grid_cell( grid_level, grid_col, grid_row, parent_grid_col, parent_grid_row, minx, miny, maxx, maxy, state, blocked, state_reason, risk_score, confidence, land_hits, hole_hits, collision_hits, grounding_hits, route_hits, boundary_hits ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, cell_batch, ) conn.commit() if aoi_batch: conn.executemany( """ INSERT INTO port_aoi( feature_id, fid, canonical_object_type, center_x, center_y, radius_m ) VALUES (?, ?, ?, ?, ?, ?) """, aoi_batch, ) conn.commit() if port_tiles: conn.executemany( f""" UPDATE tile_default SET has_port_refine = 1 WHERE z = {TARGET_Z} AND x = ? AND y = ? """, sorted(port_tiles), ) conn.commit() return port_feature_count, inserted_cells, port_cell_keys, port_tiles def relevant_feature_query(last_tile: tuple[int, int] | None = None) -> str: resume_filter = "" if last_tile is not None: last_x, last_y = last_tile resume_filter = f"\n AND (d.x > {last_x} OR (d.x = {last_x} AND d.y > {last_y}))" return f""" SELECT d.feature_id, d.canonical_object_type, d.capability, d.x, d.y, d.geom_type, g.geometry_wkt FROM navsea_detection_objects d JOIN feature_geometry_stage g ON g.feature_id = d.feature_id WHERE d.z = {TARGET_Z} AND ( d.canonical_object_type IN ('P陸域', 'P穴') OR d.capability IN ('collision', 'grounding', 'route_reference', 'boundary_reference') ) {resume_filter} ORDER BY d.x, d.y, d.feature_id """ def touch_bucket(bucket: dict[int, list[int]], key: int, index: int) -> None: counts = bucket.get(key) if counts is None: counts = [0, 0, 0, 0, 0, 0] bucket[key] = counts counts[index] += 1 def effective_geometry(raw_geom, canonical_type: str, capability: str | None): capability = capability or "" if canonical_type == "P陸域": return raw_geom, 0 if canonical_type == "P穴": return raw_geom, 1 if capability == "collision": if raw_geom.geom_type in {"Point", "MultiPoint", "LineString", "MultiLineString"}: return raw_geom.buffer(COLLISION_BUFFER_M), 2 return raw_geom.buffer(COLLISION_BUFFER_M / 2.0), 2 if capability == "grounding": if raw_geom.geom_type in {"Point", "MultiPoint", "LineString", "MultiLineString"}: return raw_geom.buffer(GROUNDING_BUFFER_M), 3 return raw_geom, 3 if capability == "route_reference": if raw_geom.geom_type in {"LineString", "MultiLineString"}: return raw_geom.buffer(ROUTE_BUFFER_M), 4 return raw_geom, 4 if capability == "boundary_reference": if raw_geom.geom_type in {"LineString", "MultiLineString"}: return raw_geom.buffer(BOUNDARY_BUFFER_M), 5 return raw_geom, 5 return None, -1 def apply_feature_to_grid( feature_geom, grid_size: float, target_bucket: dict[int, list[int]], bucket_index: int, valid_keys: set[int] | None = None, ) -> None: minx, miny, maxx, maxy = feature_geom.bounds col_min, col_max, row_min, row_max = cell_index_range(minx, miny, maxx, maxy, grid_size) prepared = prep(feature_geom) for col in range(col_min, col_max + 1): for row in range(row_min, row_max + 1): key = encode_key(col, row) if valid_keys is not None and key not in valid_keys: continue cminx, cminy, cmaxx, cmaxy = cell_bounds(col, row, grid_size) if prepared.intersects(box(cminx, cminy, cmaxx, cmaxy)): touch_bucket(target_bucket, key, bucket_index) def flush_grid_counts(conn: sqlite3.Connection, grid_level: int, bucket: dict[int, list[int]], confidence: str) -> int: if not bucket: return 0 rows: list[tuple] = [] for key, counts in bucket.items(): col, row = decode_key(key) rows.append(make_cell_row(grid_level, col, row, "open_water", 0, None, 0, confidence, counts)) conn.executemany( """ INSERT INTO grid_cell( grid_level, grid_col, grid_row, parent_grid_col, parent_grid_row, minx, miny, maxx, maxy, state, blocked, state_reason, risk_score, confidence, land_hits, hole_hits, collision_hits, grounding_hits, route_hits, boundary_hits ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(grid_level, grid_col, grid_row) DO UPDATE SET confidence=excluded.confidence, land_hits=grid_cell.land_hits + excluded.land_hits, hole_hits=grid_cell.hole_hits + excluded.hole_hits, collision_hits=grid_cell.collision_hits + excluded.collision_hits, grounding_hits=grid_cell.grounding_hits + excluded.grounding_hits, route_hits=grid_cell.route_hits + excluded.route_hits, boundary_hits=grid_cell.boundary_hits + excluded.boundary_hits """, rows, ) written = len(rows) bucket.clear() return written def merge_geometry(geoms: list): if not geoms: return None if len(geoms) == 1: return geoms[0] return unary_union(geoms) def flush_tile_features( conn: sqlite3.Connection, tile_x: int, tile_y: int, features: list[tuple[str, str | None, object]], port_cell_keys: set[int], port_tiles: set[tuple[int, int]], grid_profile: str, ) -> tuple[int, int]: grouped: list[list] = [[] for _ in range(6)] for canonical_type, capability, mercator_geom in features: effect_geom, bucket_index = effective_geometry(mercator_geom, canonical_type, capability) if effect_geom is None or bucket_index < 0 or effect_geom.is_empty: continue grouped[bucket_index].append(effect_geom) tile_200: dict[int, list[int]] = {} tile_50: dict[int, list[int]] = {} in_port_tile = (tile_x, tile_y) in port_tiles for bucket_index, geoms in enumerate(grouped): merged = merge_geometry(geoms) if merged is None or merged.is_empty: continue apply_feature_to_grid(merged, GRID_200_M, tile_200, bucket_index) if grid_profile in {GRID_PROFILE_PORT_ONLY, GRID_PROFILE_FULL_DOMAIN} and in_port_tile: apply_feature_to_grid(merged, GRID_50_M, tile_50, bucket_index, port_cell_keys) confidence_200 = "z12_full_domain_200m" if grid_profile == GRID_PROFILE_FULL_DOMAIN else "z12_sparse_override" written_200 = flush_grid_counts(conn, 200, tile_200, confidence_200) written_50 = flush_grid_counts(conn, 50, tile_50, "z12_port_refine") return written_200, written_50 def write_checkpoint( conn: sqlite3.Connection, *, processed_tiles: int, processed_features: int, written_200: int, written_50: int, last_tile: tuple[int, int] | None, phase: str, ) -> None: meta_upsert(conn, "run_phase", phase) meta_upsert(conn, "processed_tiles", str(processed_tiles)) meta_upsert(conn, "processed_features", str(processed_features)) meta_upsert(conn, "written_200", str(written_200)) meta_upsert(conn, "written_50", str(written_50)) if last_tile is None: meta_upsert(conn, "last_tile_x", "") meta_upsert(conn, "last_tile_y", "") else: meta_upsert(conn, "last_tile_x", str(last_tile[0])) meta_upsert(conn, "last_tile_y", str(last_tile[1])) meta_upsert(conn, "last_checkpoint_at", dt.datetime.now().isoformat(timespec="seconds")) conn.commit() def read_checkpoint(conn: sqlite3.Connection) -> dict[str, object]: last_x = meta_get(conn, "last_tile_x", "") last_y = meta_get(conn, "last_tile_y", "") last_tile = None if last_x != "" and last_y != "": last_tile = (int(last_x), int(last_y)) return { "phase": meta_get(conn, "run_phase", "init"), "processed_tiles": int(meta_get(conn, "processed_tiles", "0")), "processed_features": int(meta_get(conn, "processed_features", "0")), "written_200": int(meta_get(conn, "written_200", "0")), "written_50": int(meta_get(conn, "written_50", "0")), "last_tile": last_tile, } def accumulate_grid_counts( conn: sqlite3.Connection, port_cell_keys: set[int], port_tiles: set[tuple[int, int]], *, grid_profile: str, tile_limit: int | None, commit_interval: int, checkpoint: dict[str, object] | None = None, ) -> tuple[int, int, int, int]: current_tile: tuple[int, int] | None = None current_features: list[tuple[str, str | None, object]] = [] checkpoint = checkpoint or {} processed_features = int(checkpoint.get("processed_features", 0)) processed_tiles = int(checkpoint.get("processed_tiles", 0)) written_200 = int(checkpoint.get("written_200", 0)) written_50 = int(checkpoint.get("written_50", 0)) last_tile = checkpoint.get("last_tile") for cols in mysql_stream(relevant_feature_query(last_tile)): if len(cols) != 7: raise RuntimeError(f"unexpected feature column count: {len(cols)}") _, canonical_type, capability, tile_x, tile_y, _, geometry_wkt = cols tile_x_i = int(tile_x) tile_y_i = int(tile_y) tile_key = (tile_x_i, tile_y_i) if grid_profile == GRID_PROFILE_PORT_ONLY and tile_key not in port_tiles: continue if current_tile is not None and tile_key != current_tile: w200, w50 = flush_tile_features( conn, current_tile[0], current_tile[1], current_features, port_cell_keys, port_tiles, grid_profile, ) written_200 += w200 written_50 += w50 processed_tiles += 1 if processed_tiles % commit_interval == 0: write_checkpoint( conn, processed_tiles=processed_tiles, processed_features=processed_features, written_200=written_200, written_50=written_50, last_tile=current_tile, phase="running", ) print( f"已处理 tile={processed_tiles} / features={processed_features}," f"累计写回 200m={written_200} / 50m={written_50}", flush=True, ) current_features = [] if tile_limit is not None and processed_tiles >= tile_limit: current_tile = None break raw_geom = load_wkt(geometry_wkt) mercator_geom = affine_transform(raw_geom, mercator_tile_affine(tile_x_i, tile_y_i)) current_features.append((canonical_type, capability, mercator_geom)) current_tile = tile_key processed_features += 1 if current_tile is not None: w200, w50 = flush_tile_features( conn, current_tile[0], current_tile[1], current_features, port_cell_keys, port_tiles, grid_profile, ) written_200 += w200 written_50 += w50 processed_tiles += 1 write_checkpoint( conn, processed_tiles=processed_tiles, processed_features=processed_features, written_200=written_200, written_50=written_50, last_tile=current_tile, phase="grid_complete", ) return processed_features, processed_tiles, written_200, written_50 def reclassify_grid(conn: sqlite3.Connection) -> None: conn.execute( """ UPDATE grid_cell SET state = CASE WHEN land_hits > hole_hits AND land_hits > 0 THEN 'blocked' WHEN collision_hits > 0 THEN 'blocked' WHEN grounding_hits > 0 THEN 'caution' WHEN boundary_hits > 0 THEN 'caution' WHEN route_hits > 0 THEN 'preferred' ELSE 'open_water' END, blocked = CASE WHEN land_hits > hole_hits AND land_hits > 0 THEN 1 WHEN collision_hits > 0 THEN 1 ELSE 0 END, state_reason = CASE WHEN land_hits > hole_hits AND land_hits > 0 THEN 'land' WHEN collision_hits > 0 THEN 'collision' WHEN grounding_hits > 0 THEN 'grounding' WHEN boundary_hits > 0 THEN 'boundary' WHEN route_hits > 0 THEN 'route' ELSE NULL END, risk_score = CASE WHEN land_hits > hole_hits AND land_hits > 0 THEN 100 WHEN collision_hits > 0 THEN 90 WHEN grounding_hits > 0 THEN 60 WHEN boundary_hits > 0 THEN 40 WHEN route_hits > 0 THEN 20 ELSE 0 END """ ) conn.commit() def read_meta(conn: sqlite3.Connection, key: str, default: str = "") -> str: row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() return str(row[0]) if row else default def build_report(report_path: Path, db_path: Path) -> None: conn = sqlite3.connect(db_path) grid_profile = read_meta(conn, "grid_profile", GRID_PROFILE_PORT_ONLY) tile_limit = read_meta(conn, "tile_limit", "") stats = { "tile_total": conn.execute("SELECT COUNT(*) FROM tile_default").fetchone()[0], "tile_port_refine": conn.execute("SELECT COUNT(*) FROM tile_default WHERE has_port_refine = 1").fetchone()[0], "port_aoi_total": conn.execute("SELECT COUNT(*) FROM port_aoi").fetchone()[0], "grid_200_total": conn.execute("SELECT COUNT(*) FROM grid_cell WHERE grid_level = 200").fetchone()[0], "grid_50_total": conn.execute("SELECT COUNT(*) FROM grid_cell WHERE grid_level = 50").fetchone()[0], } state_rows = conn.execute( """ SELECT grid_level, state, COUNT(*) FROM grid_cell GROUP BY grid_level, state ORDER BY grid_level, state """ ).fetchall() conn.close() lines = [ "# 九州可航行性栅格测试版", "", f"- 生成时间:`{dt.datetime.now().isoformat(timespec='seconds')}`", f"- 输出 SQLite:`{db_path}`", f"- 栅格模式:`{grid_profile}`", f"- tile 限制:`{tile_limit or 'none'}`", "", "## 1. 当前产物结构", "", "- `tile_default`:所有 `z12` 瓦片的默认状态,当前默认 `open_water`", "- `port_aoi`:港口触发点与 50m 细化半径", "- `grid_cell`:保存 `200m` 主层和港口 `50m` 细化结果", "", "## 2. 统计", "", f"- `z12` tile 总数:`{stats['tile_total']}`", f"- 启用港口 50m 细化的 tile 数:`{stats['tile_port_refine']}`", f"- 港口 AOI 触发点数:`{stats['port_aoi_total']}`", f"- `200m` 覆盖格子数:`{stats['grid_200_total']}`", f"- `50m` 细化格子数:`{stats['grid_50_total']}`", "", "### 2.1 格子状态分布", "", "| grid_level | state | count |", "| --- | --- | ---: |", ] for grid_level, state, count in state_rows: lines.append(f"| `{grid_level}` | `{state}` | `{count}` |") lines.extend( [ "", "## 3. 当前测试口径", "", "- `P陸域`:直接作为 `blocked=1`", "- `P穴`:用于抵消陆地区域覆盖", "- `collision`:按 `80m` 缓冲后作为 `blocked=1`", "- `grounding`:按 `40m` 缓冲后作为 `caution`", "- `boundary_reference`:按 `30m` 缓冲后作为 `caution`", "- `route_reference`:按 `25m` 缓冲后作为 `preferred`", "", "## 4. 当前限制", "", "- 这是测试版,不是最终船型/吃水感知版", "- 当前 `200m` 层以有语义命中的格子为主,不把纯开放水域全量预烘焙进 `grid_cell`", "- 港口 `50m` 层是完整 AOI 细化;AOI 外不存完整 `50m` 全覆盖", "- 当前仍是 Web Mercator 米制近似,不是分港区本地投影高精版", ] ) report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def open_db(path: Path, *, resume: bool) -> sqlite3.Connection: if path.exists() and not resume: path.unlink() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=OFF") conn.execute("PRAGMA temp_store=MEMORY") conn.execute("PRAGMA cache_size=200000") conn.execute("PRAGMA busy_timeout=5000") return conn def main() -> None: parser = argparse.ArgumentParser(description="Build Kyushu navigability test grid from z12 NavSea geometries.") parser.add_argument("--out-db", type=Path, default=OUT_DB) parser.add_argument("--report", type=Path, default=OUT_REPORT) parser.add_argument( "--grid-profile", choices=[GRID_PROFILE_PORT_ONLY, GRID_PROFILE_FULL_DOMAIN], default=GRID_PROFILE_FULL_DOMAIN, help="200m 主层覆盖范围;full-domain 为九州全域,port-tiles 仅港口触发 tile。", ) parser.add_argument("--tile-limit", type=int, default=None, help="仅处理前 N 个 tile,用于烟测。") parser.add_argument("--resume", action="store_true", help="从已有 SQLite checkpoint 继续执行。") parser.add_argument( "--commit-interval", type=int, default=DEFAULT_COMMIT_INTERVAL, help="每处理多少个 tile 写一次 checkpoint。", ) args = parser.parse_args() conn = open_db(args.out_db, resume=args.resume) create_schema(conn) if not args.resume: conn.executemany( "INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", [ ("grid_profile", args.grid_profile), ("tile_limit", "" if args.tile_limit is None else str(args.tile_limit)), ("commit_interval", str(args.commit_interval)), ("run_phase", "init"), ("processed_tiles", "0"), ("processed_features", "0"), ("written_200", "0"), ("written_50", "0"), ("last_tile_x", ""), ("last_tile_y", ""), ], ) conn.commit() else: args.grid_profile = meta_get(conn, "grid_profile", args.grid_profile) tile_limit_meta = meta_get(conn, "tile_limit", "") args.tile_limit = int(tile_limit_meta) if tile_limit_meta else args.tile_limit args.commit_interval = int(meta_get(conn, "commit_interval", str(args.commit_interval))) checkpoint = read_checkpoint(conn) print( f"恢复执行: phase={checkpoint['phase']} " f"processed_tiles={checkpoint['processed_tiles']} " f"last_tile={checkpoint['last_tile']}", flush=True, ) tile_count = load_tile_defaults(conn) meta_upsert(conn, "run_phase", "tile_default_ready") conn.commit() print(f"已写入 {tile_count} 个 z12 tile 默认状态", flush=True) port_feature_count, port_cell_count, port_cell_keys, port_tiles = build_port_aoi(conn) meta_upsert(conn, "run_phase", "port_aoi_ready") conn.commit() print( f"已建立港口 50m AOI: port_features={port_feature_count}, " f"port_cells={port_cell_count}, port_tiles={len(port_tiles)}", flush=True, ) processed, processed_tiles, written_200, written_50 = accumulate_grid_counts( conn, port_cell_keys, port_tiles, grid_profile=args.grid_profile, tile_limit=args.tile_limit, commit_interval=args.commit_interval, checkpoint=read_checkpoint(conn) if args.resume else None, ) print( f"已完成对象落格累计写回: processed_tiles={processed_tiles}, processed_features={processed}, " f"grid200_writes={written_200}, grid50_writes={written_50}", flush=True, ) reclassify_grid(conn) meta_upsert(conn, "run_phase", "done") meta_upsert(conn, "finished_at", dt.datetime.now().isoformat(timespec="seconds")) conn.execute("ANALYZE") conn.commit() conn.close() build_report(args.report, args.out_db) print(f"完成: 200m rows={written_200}, 50m upserts={written_50}", flush=True) print(f"输出 SQLite: {args.out_db.resolve()}", flush=True) print(f"输出报告: {args.report.resolve()}", flush=True) if __name__ == "__main__": main()