Files
pbf/navsea_build_kyushu_navigability_test.py

985 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()