添加全国三层生成与港名查FPC工具

This commit is contained in:
OpenAI Codex
2026-05-02 14:32:06 +08:00
parent 15994b5c7e
commit 2513dd56f8
8 changed files with 5920 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,565 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import glob
import json
import math
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import xml.etree.ElementTree as ET
import pymysql
from shapely.geometry import LineString, MultiLineString, box, mapping
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_INPUT_GLOB = "coastline/C23-06_*_GML.zip"
DEFAULT_OUT_DIR = "out/coastline/japan_coast_grid_mysql"
LAYER_NAME = "coast_200m"
CELL_SIZE_M = 200.0
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
NS = {"gml": "http://www.opengis.net/gml/3.2"}
@dataclass(frozen=True)
class SourcePackage:
path: Path
prefecture: str
code: str
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def align_floor(value: float, step: float) -> float:
return math.floor(value / step) * step
def align_ceil(value: float, step: float) -> float:
return math.ceil(value / step) * step
def parse_text_list(text: str) -> list[float]:
return [float(part) for part in text.split() if part]
def detect_prefecture(meta_xml: str) -> str:
title_start = meta_xml.find("<title>")
if title_start == -1:
return "unknown"
title_end = meta_xml.find("</title>", title_start)
if title_end == -1:
return "unknown"
return meta_xml[title_start + 7:title_end].strip()
def load_package(path: Path) -> SourcePackage:
with zipfile.ZipFile(path) as zf:
meta_name = next(name for name in zf.namelist() if "META" in name and name.endswith(".xml"))
meta_xml = zf.read(meta_name).decode("shift_jis", errors="replace")
prefecture = detect_prefecture(meta_xml)
code = path.stem.replace("_GML", "")
return SourcePackage(path=path, prefecture=prefecture, code=code)
def iter_coastline_lines(zip_path: Path) -> Iterable[LineString]:
with zipfile.ZipFile(zip_path) as zf:
xml_name = next(name for name in zf.namelist() if name.endswith(".xml") and "META" not in name)
root = ET.fromstring(zf.read(xml_name))
for curve in root.findall(".//gml:Curve", NS):
coords: list[tuple[float, float]] = []
for pos_list in curve.findall(".//gml:posList", NS):
if not pos_list.text:
continue
values = parse_text_list(pos_list.text)
if len(values) < 4 or len(values) % 2 != 0:
continue
for i in range(0, len(values), 2):
lat = values[i]
lon = values[i + 1]
coords.append((mercator_x(lon), mercator_y(lat)))
if len(coords) >= 2:
yield LineString(coords)
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database() -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
ddl = [
"DROP TABLE IF EXISTS navsea_grid_cell",
"DROP TABLE IF EXISTS navsea_grid_package_stat",
"DROP TABLE IF EXISTS navsea_grid_layer_meta",
"""
CREATE TABLE navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_package_stat (
source_code VARCHAR(64) NOT NULL,
prefecture VARCHAR(128) NOT NULL,
zip_path TEXT NOT NULL,
line_count BIGINT NOT NULL,
point_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
grid_min_lon DOUBLE NOT NULL,
grid_min_lat DOUBLE NOT NULL,
grid_max_lon DOUBLE NOT NULL,
grid_max_lat DOUBLE NOT NULL,
blocked_count BIGINT NOT NULL,
candidate_count BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in ddl:
cur.execute(stmt)
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
row_idx = row_idx,
col_idx = col_idx,
cell_size_m = cell_size_m,
state_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(state_name), state_name),
class_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(class_name), class_name),
source_name = IF(VALUES(state_name) = 'HARD_BLOCKED' AND state_name <> 'HARD_BLOCKED', VALUES(source_name), source_name),
min_lon = min_lon,
min_lat = min_lat,
max_lon = max_lon,
max_lat = max_lat
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str | None,
) -> None:
cur.execute(
"""
INSERT INTO navsea_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def main() -> None:
parser = argparse.ArgumentParser(description="Build Japan coastline 200m grid directly into MySQL")
parser.add_argument(
"--input",
dest="inputs",
action="append",
default=[],
help="海岸线 GML zip。可重复指定默认自动扫描 coastline/C23-06_*_GML.zip",
)
parser.add_argument(
"--db-name",
default=DB_NAME,
help="MySQL 数据库名",
)
parser.add_argument(
"--out-dir",
default=DEFAULT_OUT_DIR,
help="输出目录,用于可选 GeoJSON 和元数据",
)
parser.add_argument("--buffer-m", type=float, default=50.0, help="海岸线缓冲距离,默认 50m")
parser.add_argument(
"--margin-cells",
type=int,
default=2,
help="输出范围外扩的格子数,默认 2 个格子",
)
parser.add_argument(
"--write-geojson",
action="store_true",
help="同时输出 coastline.geojson 和 grid.geojson",
)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
if args.inputs:
input_paths: list[Path] = []
for item in args.inputs:
if any(ch in item for ch in "*?[]"):
input_paths.extend(Path(path) for path in sorted(glob.glob(item)))
else:
input_paths.append(Path(item))
else:
input_paths = [Path(path) for path in sorted(glob.glob(str(project_root / DEFAULT_INPUT_GLOB)))]
if not input_paths:
raise SystemExit("no coastline packages found")
packages: list[SourcePackage] = []
for path in input_paths:
if not path.is_absolute():
path = project_root / path
if not path.exists():
raise SystemExit(f"missing input: {path}")
packages.append(load_package(path))
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database()
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
total_lines = 0
total_points = 0
package_stats: list[dict] = []
for package in packages:
print(f"[{package.code}] 开始处理 {package.path.name}")
lines = list(iter_coastline_lines(package.path))
if not lines:
print(f"[{package.code}] 没有可用海岸线,跳过")
continue
coastline_geom = MultiLineString([list(line.coords) for line in lines])
coastline_buffer = coastline_geom.buffer(args.buffer_m, cap_style=2, join_style=2)
prepared_buffer = prep(coastline_buffer)
coast_bounds = coastline_geom.bounds
minx = align_floor(coast_bounds[0] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
miny = align_floor(coast_bounds[1] - args.buffer_m - args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxx = align_ceil(coast_bounds[2] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
maxy = align_ceil(coast_bounds[3] + args.buffer_m + args.margin_cells * CELL_SIZE_M, CELL_SIZE_M)
line_count = len(lines)
point_count = sum(len(line.coords) for line in lines)
total_lines += line_count
total_points += point_count
package_blocked = 0
package_candidate = 0
package_rows: list[tuple] = []
ix0 = int(round(minx / CELL_SIZE_M))
iy0 = int(round(miny / CELL_SIZE_M))
ix1 = int(round(maxx / CELL_SIZE_M))
iy1 = int(round(maxy / CELL_SIZE_M))
for ix in range(ix0, ix1):
cell_minx = ix * CELL_SIZE_M
cell_maxx = cell_minx + CELL_SIZE_M
for iy in range(iy0, iy1):
cell_miny = iy * CELL_SIZE_M
cell_maxy = cell_miny + CELL_SIZE_M
cell = box(cell_minx, cell_miny, cell_maxx, cell_maxy)
intersects = prepared_buffer.intersects(cell)
if intersects:
cell_id = f"{ix}:{iy}"
package_rows.append(
(
LAYER_NAME,
cell_id,
iy,
ix,
CELL_SIZE_M,
"HARD_BLOCKED",
"HARD_BLOCKED",
package.code,
cell_minx,
cell_miny,
cell_maxx,
cell_maxy,
)
)
package_blocked += 1
else:
package_candidate += 1
if len(package_rows) >= 5000:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
print(
f"[{package.code}] 已写入 {len(package_rows)} 行,"
f"blocked={package_blocked} candidate={package_candidate}"
)
package_rows.clear()
if package_rows:
with conn.cursor() as cur:
insert_cells(cur, package_rows)
conn.commit()
package_stats.append(
{
"source_code": package.code,
"prefecture": package.prefecture,
"zip_path": str(package.path),
"line_count": line_count,
"point_count": point_count,
"bbox_mercator": [coast_bounds[0], coast_bounds[1], coast_bounds[2], coast_bounds[3]],
"grid_bounds_mercator": [minx, miny, maxx, maxy],
"blocked_count": package_blocked,
"candidate_count": package_candidate,
}
)
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO navsea_grid_package_stat(
source_code, prefecture, zip_path, line_count, point_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat,
grid_min_lon, grid_min_lat, grid_max_lon, grid_max_lat,
blocked_count, candidate_count
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
""",
(
package.code,
package.prefecture,
str(package.path),
line_count,
point_count,
coast_bounds[0],
coast_bounds[1],
coast_bounds[2],
coast_bounds[3],
minx,
miny,
maxx,
maxy,
package_blocked,
package_candidate,
),
)
conn.commit()
print(
f"[{package.code}] 完成 line={line_count} point={point_count} "
f"blocked={package_blocked} candidate={package_candidate}"
)
with conn.cursor() as cur:
cur.execute("ANALYZE TABLE navsea_grid_cell")
cur.execute("ANALYZE TABLE navsea_grid_package_stat")
cur.execute("ANALYZE TABLE navsea_grid_layer_meta")
conn.commit()
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s", (LAYER_NAME,))
final_total = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "HARD_BLOCKED"),
)
blocked_count = int(cur.fetchone()[0] or 0)
cur.execute(
f"SELECT COUNT(*) FROM navsea_grid_cell WHERE layer_name=%s AND state_name=%s",
(LAYER_NAME, "NAVIGABLE_CANDIDATE"),
)
candidate_count = int(cur.fetchone()[0] or 0)
cur.execute(
"""
SELECT MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE layer_name=%s
""",
(LAYER_NAME,),
)
bbox_row = cur.fetchone()
grid_bbox = (
float(bbox_row[0]) if bbox_row and bbox_row[0] is not None else float("inf"),
float(bbox_row[1]) if bbox_row and bbox_row[1] is not None else float("inf"),
float(bbox_row[2]) if bbox_row and bbox_row[2] is not None else float("-inf"),
float(bbox_row[3]) if bbox_row and bbox_row[3] is not None else float("-inf"),
)
build_time = dt.datetime.now().isoformat(timespec="seconds")
grid_bbox_lonlat = mercator_bbox_to_lonlat(*grid_bbox)
manifest = {
"build_time": build_time,
"db_name": args.db_name,
"source_scope": "japan",
"source_code": "C23-06_*",
"source_count": len(packages),
"source_packages": [str(pkg.path) for pkg in packages],
"coastline_line_count": total_lines,
"coastline_point_count": total_points,
"grid_cell_count": final_total,
"blocked_count": blocked_count,
"candidate_count": candidate_count,
"grid_bounds_lonlat": list(grid_bbox_lonlat),
"package_stats": package_stats,
"notes": "全国海岸线 200m 硬阻塞栅格,直接写入 MySQL",
}
manifest_path = out_dir / "japan_coast_grid_mysql.manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=LAYER_NAME,
description="全国海岸线 200m 硬阻塞格",
source_desc="coastline/C23-06_*_GML.zip",
cell_size_m=CELL_SIZE_M,
feature_count=final_total,
bbox=grid_bbox_lonlat,
export_file=str(manifest_path.relative_to(project_root)),
)
conn.commit()
print("完成")
print(f" MySQL: {args.db_name}")
print(f" packages={len(packages)}")
print(f" coastline_lines={total_lines}")
print(f" grid_cells={final_total}")
print(f" blocked={blocked_count}")
print(f" candidate={candidate_count}")
print(f" manifest: {manifest_path}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
from typing import Iterable
import mapbox_vector_tile
import pymysql
from shapely.geometry import box, shape
from shapely.prepared import prep
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_TILE_ROOT = Path("/home/wwwroot/pbf-delivery-full-20260418-rebuild")
DEFAULT_TILE_Z = 12
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/japan_national"
DEFAULT_SOURCE_DESC = "pbf-delivery-full-20260418-rebuild z12"
HAZARD_CELL_M = 50.0
HAZARD_LAYER_NAME = "hazard_50m"
HAZARD_DESCRIPTION = "全国 PBF 海上障碍 50m 黄格"
HAZARD_LAYERS = (
"navigation_hazard_area",
"fixed_fishing_gear_area",
"anchor_caution_hazard_area",
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
)
BREAKWATER_LAYER = "baseline_area"
POINT_LAYERS = {
"navigation_hazard_point",
"anchor_caution_hazard_point",
"navigation_marks",
}
HAZARD_CANONICAL_OBJECT_TYPES = {
"breakwater",
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mercator_x(lon: float) -> float:
return RADIUS * math.radians(lon)
def mercator_y(lat: float) -> float:
lat = max(min(lat, MAX_MERCATOR_LAT), -MAX_MERCATOR_LAT)
return RADIUS * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def rect_geojson(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
},
}
def local_name(tag: str) -> str:
return tag.split("}", 1)[-1]
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def stream_geojson(path: Path, features: Iterable[dict]) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
count = 0
with path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for feature in features:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def mysql_connect(database: str | None = None):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"charset": "utf8mb4",
"autocommit": False,
"cursorclass": pymysql.cursors.Cursor,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
if database:
kwargs["database"] = database
return pymysql.connect(**kwargs)
def ensure_database(name: str) -> None:
conn = mysql_connect()
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
finally:
conn.close()
def ensure_schema(conn) -> None:
stmts = [
"""
CREATE TABLE IF NOT EXISTS navsea_grid_layer_meta (
layer_name VARCHAR(32) NOT NULL PRIMARY KEY,
description VARCHAR(255) NOT NULL,
source_desc TEXT NOT NULL,
cell_size_m DOUBLE NOT NULL,
feature_count BIGINT NOT NULL,
bbox_min_lon DOUBLE NOT NULL,
bbox_min_lat DOUBLE NOT NULL,
bbox_max_lon DOUBLE NOT NULL,
bbox_max_lat DOUBLE NOT NULL,
export_file VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
"""
CREATE TABLE IF NOT EXISTS navsea_grid_cell (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
layer_name VARCHAR(32) NOT NULL,
cell_id VARCHAR(64) NOT NULL,
row_idx INT NOT NULL,
col_idx INT NOT NULL,
cell_size_m DOUBLE NOT NULL,
state_name VARCHAR(32) NOT NULL,
class_name VARCHAR(32) NOT NULL,
source_name VARCHAR(191) NOT NULL,
min_lon DOUBLE NOT NULL,
min_lat DOUBLE NOT NULL,
max_lon DOUBLE NOT NULL,
max_lat DOUBLE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_layer_cell (layer_name, cell_id),
KEY idx_layer_state (layer_name, state_name),
KEY idx_layer_rowcol (layer_name, row_idx, col_idx)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
]
with conn.cursor() as cur:
for stmt in stmts:
cur.execute(stmt)
conn.commit()
def replace_layer_rows(conn, layer_name: str) -> None:
with conn.cursor() as cur:
cur.execute("DELETE FROM navsea_grid_cell WHERE layer_name=%s", (layer_name,))
cur.execute("DELETE FROM navsea_grid_layer_meta WHERE layer_name=%s", (layer_name,))
conn.commit()
def insert_cells(cur, rows: list[tuple]) -> None:
cur.executemany(
"""
INSERT INTO navsea_grid_cell
(layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name, source_name,
min_lon, min_lat, max_lon, max_lat)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
rows,
)
def upsert_meta(
cur,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
feature_count: int,
bbox: tuple[float, float, float, float],
export_file: str,
) -> None:
cur.execute(
"""
INSERT INTO navsea_grid_layer_meta
(layer_name, description, source_desc, cell_size_m, feature_count,
bbox_min_lon, bbox_min_lat, bbox_max_lon, bbox_max_lat, export_file)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
description=VALUES(description),
source_desc=VALUES(source_desc),
cell_size_m=VALUES(cell_size_m),
feature_count=VALUES(feature_count),
bbox_min_lon=VALUES(bbox_min_lon),
bbox_min_lat=VALUES(bbox_min_lat),
bbox_max_lon=VALUES(bbox_max_lon),
bbox_max_lat=VALUES(bbox_max_lat),
export_file=VALUES(export_file)
""",
(
layer_name,
description,
source_desc,
cell_size_m,
feature_count,
bbox[0],
bbox[1],
bbox[2],
bbox[3],
export_file,
),
)
def tile_mercator_bounds(z: int, x: int, y: int) -> tuple[float, float, float, float]:
n = 2**z
lon_left = x / n * 360.0 - 180.0
lon_right = (x + 1) / n * 360.0 - 180.0
lat_top = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
lat_bottom = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return mercator_x(lon_left), mercator_y(lat_bottom), mercator_x(lon_right), mercator_y(lat_top)
def transform_geom_from_tile(geom, bounds_m: tuple[float, float, float, float], extent: int):
minx, miny, maxx, maxy = bounds_m
dx = maxx - minx
dy = maxy - miny
def coord(x: float, y: float):
mx = minx + (x / extent) * dx
my = miny + (y / extent) * dy
return mx, my
def walk(obj):
if isinstance(obj[0], (int, float)):
return coord(obj[0], obj[1])
return [walk(item) for item in obj]
return walk(geom)
def iter_source_tiles(tile_root: Path, zoom: int) -> list[Path]:
zoom_root = tile_root / str(zoom)
if not zoom_root.exists():
raise SystemExit(f"missing tile zoom root: {zoom_root}")
return sorted(zoom_root.glob("*/*.pbf"))
def iter_hazard_cells(tile_root: Path, zoom: int) -> Iterable[tuple[int, int]]:
hazard_cells: set[tuple[int, int]] = set()
tiles = iter_source_tiles(tile_root, zoom)
for index, p in enumerate(tiles, start=1):
try:
tx = int(p.parent.name)
ty = int(p.stem)
except ValueError:
continue
try:
tile = mapbox_vector_tile.decode(p.read_bytes())
except Exception as exc:
print(f"[hazard_50m] 跳过无法解析的 tile: {p} ({exc.__class__.__name__})")
continue
if index % 5000 == 0:
print(f"[hazard_50m] 已扫描 tile {index}/{len(tiles)}: {p.parent.parent.name}/{tx}/{ty}.pbf")
for layer_name in HAZARD_LAYERS + (BREAKWATER_LAYER,):
layer = tile.get(layer_name)
if not layer:
continue
extent = int(layer.get("extent") or 1048576)
minx, miny, maxx, maxy = tile_mercator_bounds(zoom, tx, ty)
for feature in layer.get("features", []):
props = feature.get("properties") or {}
if layer_name == BREAKWATER_LAYER and props.get("canonical_object_type") not in HAZARD_CANONICAL_OBJECT_TYPES:
continue
if (
props.get("canonical_object_type") == "fish_reef"
or props.get("chart_symbol_code") == "fish_reef"
or props.get("class_name") == "魚礁"
):
continue
geom = feature.get("geometry") or {}
coords = geom.get("coordinates")
if not coords:
continue
shp_coords = transform_geom_from_tile(coords, (minx, miny, maxx, maxy), extent)
shp = shape({"type": geom.get("type"), "coordinates": shp_coords})
if shp.is_empty:
continue
if layer_name in POINT_LAYERS:
shp = shp.buffer(25.0)
minx2, miny2, maxx2, maxy2 = shp.bounds
start_x = math.floor(minx2 / HAZARD_CELL_M) * HAZARD_CELL_M
start_y = math.floor(miny2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_x = math.ceil(maxx2 / HAZARD_CELL_M) * HAZARD_CELL_M
end_y = math.ceil(maxy2 / HAZARD_CELL_M) * HAZARD_CELL_M
prep_geom = prep(shp)
cy = start_y
while cy < end_y:
cx = start_x
while cx < end_x:
cell = box(cx, cy, cx + HAZARD_CELL_M, cy + HAZARD_CELL_M)
if prep_geom.intersects(cell):
hazard_cells.add((int(round(cx / HAZARD_CELL_M)), int(round(cy / HAZARD_CELL_M))))
cx += HAZARD_CELL_M
cy += HAZARD_CELL_M
return sorted(hazard_cells, key=lambda item: (item[1], item[0]))
def cell_bbox_from_mercator(ix: int, iy: int, cell_m: float) -> tuple[float, float, float, float]:
minx = ix * cell_m
miny = iy * cell_m
maxx = minx + cell_m
maxy = miny + cell_m
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def import_hazard_layer(
conn,
*,
layer_name: str,
description: str,
source_desc: str,
cell_size_m: float,
tile_root: Path,
zoom: int,
export_path: Path,
) -> tuple[int, tuple[float, float, float, float]]:
cells = iter_hazard_cells(tile_root, zoom)
batch: list[tuple] = []
count = 0
export_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
with conn.cursor() as cur:
print(f"[{layer_name}] 开始扫描全国 PBF 危险层:{tile_root} / z{zoom}")
with export_path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for ix, iy in cells:
min_lon, min_lat, max_lon, max_lat = cell_bbox_from_mercator(ix, iy, cell_size_m)
export_bbox[0] = min(export_bbox[0], min_lon)
export_bbox[1] = min(export_bbox[1], min_lat)
export_bbox[2] = max(export_bbox[2], max_lon)
export_bbox[3] = max(export_bbox[3], max_lat)
cell_id = f"{ix}:{iy}"
batch.append(
(
layer_name,
cell_id,
iy,
ix,
cell_size_m,
"HAZARD_50M",
"HAZARD",
source_desc,
min_lon,
min_lat,
max_lon,
max_lat,
)
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": iy,
"col": ix,
"cell_size_m": cell_size_m,
"state_name": "HAZARD_50M",
"class_name": "HAZARD",
"source_name": source_desc,
},
"geometry": rect_geojson(min_lon, min_lat, max_lon, max_lat)["geometry"],
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
if len(batch) >= 5000:
insert_cells(cur, batch)
conn.commit()
batch.clear()
if count % 20000 == 0:
print(f"[{layer_name}] 已处理 {count} 个格子 ...")
if batch:
insert_cells(cur, batch)
conn.commit()
fh.write("\n]}\n")
if count == 0:
raise RuntimeError(f"{layer_name} 扫描结果为空,请检查 tile_root={tile_root} zoom={zoom}")
print(f"[{layer_name}] 导入完成:{count} 条,导出 {export_path}")
return count, (export_bbox[0], export_bbox[1], export_bbox[2], export_bbox[3])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="重算全国 50x50 海上障碍格并写入统一 MySQL 库。")
parser.add_argument("--db-name", default=DB_NAME, help="MySQL 数据库名")
parser.add_argument("--tile-root", type=Path, default=DEFAULT_TILE_ROOT, help="全国 PBF 根目录")
parser.add_argument("--zoom", type=int, default=DEFAULT_TILE_Z, help="PBF 瓦片 zoom默认 12")
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR, help="GeoJSON 输出目录")
parser.add_argument("--source-desc", default=DEFAULT_SOURCE_DESC, help="写入元数据的来源说明")
return parser.parse_args()
def main() -> None:
args = parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
ensure_database(args.db_name)
conn = mysql_connect(args.db_name)
try:
print(f"数据库 {args.db_name} 已连接,准备初始化表结构 ...")
ensure_schema(conn)
replace_layer_rows(conn, HAZARD_LAYER_NAME)
hazard_export = out_dir / "hazard_50m_grid.geojson"
hazard_count, hazard_bbox = import_hazard_layer(
conn,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
tile_root=args.tile_root,
zoom=args.zoom,
export_path=hazard_export,
)
with conn.cursor() as cur:
upsert_meta(
cur,
layer_name=HAZARD_LAYER_NAME,
description=HAZARD_DESCRIPTION,
source_desc=args.source_desc,
cell_size_m=HAZARD_CELL_M,
feature_count=hazard_count,
bbox=hazard_bbox,
export_file=str(hazard_export.relative_to(project_root)),
)
conn.commit()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layer": {
"name": HAZARD_LAYER_NAME,
"count": hazard_count,
"bbox_lonlat": list(hazard_bbox),
"cell_size_m": HAZARD_CELL_M,
"export_file": str(hazard_export.relative_to(project_root)),
"source_desc": args.source_desc,
"tile_root": str(args.tile_root),
"zoom": args.zoom,
},
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("已导入 MySQL 数据库:", args.db_name)
print(" - hazard_50m:", hazard_count, hazard_export)
print(" - manifest:", manifest_path)
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,485 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import datetime as dt
import json
import math
from pathlib import Path
import pymysql
DB_NAME = "navsea_japan_coast_grid"
DB_USER = "root"
DB_PASSWORD = "2chi9ks2"
DB_HOST = "localhost"
DB_SOCKET = "/tmp/mysql.sock"
DEFAULT_OUT_DIR = "src/pbf/coastline-mysql/japan_national"
DENSITY_OVERVIEW_FILE = "density_overview_grid.geojson"
CELL_SIZES = {
"coast_200m": 200.0,
"fish_port_20m": 20.0,
"hazard_50m": 50.0,
}
RADIUS = 6378137.0
MAX_MERCATOR_LAT = 85.0511287798066
def mysql_connect(database: str, *, cursorclass=pymysql.cursors.Cursor):
kwargs = {
"host": DB_HOST,
"user": DB_USER,
"password": DB_PASSWORD,
"database": database,
"charset": "utf8mb4",
"cursorclass": cursorclass,
}
if DB_SOCKET:
kwargs["unix_socket"] = DB_SOCKET
return pymysql.connect(**kwargs)
def rect_geometry(min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> dict:
return {
"type": "Polygon",
"coordinates": [
[
[min_lon, min_lat],
[max_lon, min_lat],
[max_lon, max_lat],
[min_lon, max_lat],
[min_lon, min_lat],
]
],
}
def lon_from_mercator(x: float) -> float:
return math.degrees(x / RADIUS)
def lat_from_mercator(y: float) -> float:
return math.degrees(2.0 * math.atan(math.exp(y / RADIUS)) - math.pi / 2.0)
def mercator_bbox_to_lonlat(minx: float, miny: float, maxx: float, maxy: float) -> tuple[float, float, float, float]:
return (
lon_from_mercator(minx),
lat_from_mercator(miny),
lon_from_mercator(maxx),
lat_from_mercator(maxy),
)
def normalize_bbox(layer_name: str, min_lon: float, min_lat: float, max_lon: float, max_lat: float) -> tuple[float, float, float, float]:
if layer_name == "coast_200m" and (
abs(min_lon) > 180.0 or abs(max_lon) > 180.0 or abs(min_lat) > 90.0 or abs(max_lat) > 90.0
):
return mercator_bbox_to_lonlat(min_lon, min_lat, max_lon, max_lat)
return min_lon, min_lat, max_lon, max_lat
def fetch_stats(conn, where_sql: str, params: tuple) -> tuple[int, list[float] | None]:
sql = f"""
SELECT COUNT(*), MIN(min_lon), MIN(min_lat), MAX(max_lon), MAX(max_lat)
FROM navsea_grid_cell
WHERE {where_sql}
"""
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
count = int(row[0] or 0)
if count == 0:
return 0, None
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
params[0] if params else "",
float(row[1]),
float(row[2]),
float(row[3]),
float(row[4]),
)
return count, [min_lon, min_lat, max_lon, max_lat]
def export_layer(
conn,
*,
path: Path,
where_sql: str,
params: tuple,
) -> int:
path.parent.mkdir(parents=True, exist_ok=True)
sql = f"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE {where_sql}
ORDER BY row_idx, col_idx
"""
count = 0
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
cur.execute(sql, params)
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
row[0],
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
first = False
count += 1
fh.write("\n]}\n")
return count
def _range_for_overlap(min_value: float, max_value: float, fine_size_m: float) -> tuple[int, int]:
start = int(math.floor(min_value / fine_size_m))
end = int(math.floor((max_value - 1e-9) / fine_size_m))
return start, end
def _overlaps_finer_cells(row_idx: int, col_idx: int, coarse_size_m: float, fine_size_m: float, fine_keys: set[tuple[int, int]]) -> bool:
min_x = col_idx * coarse_size_m
min_y = row_idx * coarse_size_m
max_x = min_x + coarse_size_m
max_y = min_y + coarse_size_m
row_start, row_end = _range_for_overlap(min_y, max_y, fine_size_m)
col_start, col_end = _range_for_overlap(min_x, max_x, fine_size_m)
for fine_row in range(row_start, row_end + 1):
for fine_col in range(col_start, col_end + 1):
if (fine_row, fine_col) in fine_keys:
return True
return False
def export_density_overview(conn, *, path: Path) -> tuple[int, dict]:
path.parent.mkdir(parents=True, exist_ok=True)
kept_counts = {
"20": 0,
"50": 0,
"200": 0,
}
kept_bbox = [float("inf"), float("inf"), float("-inf"), float("-inf")]
keys_20: set[tuple[int, int]] = set()
keys_50: set[tuple[int, int]] = set()
def write_feature(fh, feature: dict, first: bool) -> bool:
if not first:
fh.write(",\n")
fh.write(json.dumps(feature, ensure_ascii=False))
return False
with conn.cursor(pymysql.cursors.SSCursor) as cur, path.open("w", encoding="utf-8") as fh:
fh.write('{"type":"FeatureCollection","features":[\n')
first = True
# 20m: always keep
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("fish_port_20m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
keys_20.add((int(row_idx), int(col_idx)))
kept_counts["20"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": int(row_idx),
"col": int(col_idx),
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "20",
"density_level": 3,
"density_name": "高密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
# 50m: keep only when no 20m cell overlaps this area
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("hazard_50m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["hazard_50m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
keys_50.add((row_idx_i, col_idx_i))
kept_counts["50"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": row_idx_i,
"col": col_idx_i,
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "50",
"density_level": 2,
"density_name": "中密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
# 200m: keep only when neither 20m nor 50m cell overlaps this area
cur.execute(
"""
SELECT layer_name, cell_id, row_idx, col_idx, cell_size_m, state_name, class_name,
source_name, min_lon, min_lat, max_lon, max_lat
FROM navsea_grid_cell
WHERE layer_name=%s
ORDER BY row_idx, col_idx
""",
("coast_200m",),
)
for row in cur:
(
layer_name,
cell_id,
row_idx,
col_idx,
cell_size_m,
state_name,
class_name,
source_name,
min_lon,
min_lat,
max_lon,
max_lat,
) = row
row_idx_i = int(row_idx)
col_idx_i = int(col_idx)
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["coast_200m"], CELL_SIZES["fish_port_20m"], keys_20):
continue
if _overlaps_finer_cells(row_idx_i, col_idx_i, CELL_SIZES["coast_200m"], CELL_SIZES["hazard_50m"], keys_50):
continue
kept_counts["200"] += 1
min_lon, min_lat, max_lon, max_lat = normalize_bbox(
layer_name,
float(min_lon),
float(min_lat),
float(max_lon),
float(max_lat),
)
kept_bbox[0] = min(kept_bbox[0], min_lon)
kept_bbox[1] = min(kept_bbox[1], min_lat)
kept_bbox[2] = max(kept_bbox[2], max_lon)
kept_bbox[3] = max(kept_bbox[3], max_lat)
feature = {
"type": "Feature",
"properties": {
"layer_name": layer_name,
"cell_id": cell_id,
"row": row_idx_i,
"col": col_idx_i,
"cell_size_m": float(cell_size_m),
"state_name": state_name,
"class_name": class_name,
"source_name": source_name,
"density_key": "200",
"density_level": 1,
"density_name": "低密度",
},
"geometry": rect_geometry(min_lon, min_lat, max_lon, max_lat),
}
first = write_feature(fh, feature, first)
fh.write("\n]}\n")
total = kept_counts["20"] + kept_counts["50"] + kept_counts["200"]
if total == 0:
raise RuntimeError("density overview export produced no features")
summary = {
"count": total,
"by_density": kept_counts,
"bbox": None if kept_bbox[0] == float("inf") else kept_bbox,
}
return total, summary
def main() -> None:
parser = argparse.ArgumentParser(description="Export NavSea MySQL grid layers to static GeoJSON assets")
parser.add_argument("--db-name", default=DB_NAME)
parser.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
args = parser.parse_args()
project_root = Path(__file__).resolve().parent.parent
out_dir = Path(args.out_dir)
if not out_dir.is_absolute():
out_dir = project_root / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
layers = {
"coast_200m": {
"file": "coast_200m_grid.geojson",
"where": "layer_name=%s",
"params": ("coast_200m",),
"cell_size_m": 200.0,
},
"fish_port_20m": {
"file": "fish_port_20m_grid.geojson",
"where": "layer_name=%s",
"params": ("fish_port_20m",),
"cell_size_m": 20.0,
},
"hazard_50m": {
"file": "hazard_50m_grid.geojson",
"where": "layer_name=%s",
"params": ("hazard_50m",),
"cell_size_m": 50.0,
},
}
manifest_layers: dict[str, dict] = {}
conn = mysql_connect(args.db_name)
try:
for layer_name, spec in layers.items():
out_path = out_dir / spec["file"]
count = export_layer(conn, path=out_path, where_sql=spec["where"], params=spec["params"])
stat_count, bbox = fetch_stats(conn, spec["where"], spec["params"])
if count != stat_count:
raise RuntimeError(f"{layer_name} export count mismatch: {count} != {stat_count}")
manifest_layers[layer_name] = {
"count": count,
"bbox_lonlat": bbox,
"cell_size_m": spec["cell_size_m"],
"export_file": str(out_path.relative_to(project_root)),
}
print(f"{layer_name}: {count} -> {out_path}")
density_path = out_dir / DENSITY_OVERVIEW_FILE
density_count, density_summary = export_density_overview(conn, path=density_path)
manifest_layers["density_overview"] = {
"count": density_count,
"bbox_lonlat": density_summary["bbox"],
"cell_size_m": None,
"export_file": str(density_path.relative_to(project_root)),
"by_density": density_summary["by_density"],
}
print(f"density_overview: {density_count} -> {density_path}")
finally:
conn.close()
manifest = {
"database": args.db_name,
"generated_at": dt.datetime.now().isoformat(timespec="seconds"),
"layers": manifest_layers,
}
manifest_path = out_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest: {manifest_path}")
if __name__ == "__main__":
main()