docs(pbf): archive Full production provenance and engineering history

This commit is contained in:
OpenAI Codex
2026-10-13 14:02:56 +08:00
parent 1fef4c6dd1
commit 07d16c38a8
381 changed files with 107826 additions and 527 deletions

View File

@@ -0,0 +1,984 @@
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()