添加全国三层生成与港名查FPC工具
This commit is contained in:
485
coastline/export_navgrid_mysql_assets.py
Normal file
485
coastline/export_navgrid_mysql_assets.py
Normal 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()
|
||||
Reference in New Issue
Block a user