1042 lines
37 KiB
Python
1042 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CM93 -> MySQL invalid Area/Coverage 源记录 + 邻接拓扑追踪审计
|
||
Version: v5.0
|
||
|
||
用途:
|
||
- 延续 V4,只针对已确认的 52 个 invalid Area + 3 个 invalid Coverage。
|
||
- 不修复 geometry,不写数据库,不做 PBF。
|
||
- 当前 CM93JAPAN 是局部子集;跨未导入海域连续性不作为 FAIL。
|
||
|
||
V5 重点回答:
|
||
1. invalid point 是否被同 Cell 的其他 Area(包括有效 Area)共同使用?
|
||
2. invalid point 是邻接对象的 vertex、segment interior,还是仅落在内部?
|
||
3. 同一点是否形成多个 object class 的共享拓扑簇?
|
||
4. chart_feature.coverage_id 与 invalid Coverage 是否有关联?
|
||
5. source_record_index 能否在 chart_raw_record 中找到对应/邻近原始记录?
|
||
6. invalid Feature 的 attributes 是什么?
|
||
7. 当前 DB 是否保留足够的 raw/topology 中间数据,可以继续从 MySQL 追源?
|
||
8. 生成 GeoJSON,供人工用 QGIS/geojson.io 等查看 invalid polygon、invalid point、共享邻居。
|
||
|
||
依赖:
|
||
pip install pymysql shapely
|
||
|
||
运行:
|
||
python cm93_mysql_invalid_trace_v5.py ^
|
||
--host 127.0.0.1 ^
|
||
--port 3306 ^
|
||
--user root ^
|
||
--database cm93_chart ^
|
||
--dataset-id 1
|
||
|
||
输出自动打 zip,直接上传整个 zip。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import getpass
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sys
|
||
import traceback
|
||
import zipfile
|
||
from collections import Counter, defaultdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||
|
||
try:
|
||
import pymysql
|
||
from pymysql.cursors import DictCursor
|
||
except ImportError:
|
||
print("缺少 pymysql:pip install pymysql shapely")
|
||
sys.exit(2)
|
||
|
||
try:
|
||
try:
|
||
from shapely import from_wkb as _from_wkb
|
||
def load_wkb(data: bytes):
|
||
return _from_wkb(data)
|
||
except ImportError:
|
||
from shapely import wkb as _wkb
|
||
def load_wkb(data: bytes):
|
||
return _wkb.loads(data)
|
||
|
||
from shapely.geometry import Point, LineString, box, mapping
|
||
from shapely.validation import explain_validity
|
||
except ImportError:
|
||
print("缺少 shapely:pip install pymysql shapely")
|
||
sys.exit(2)
|
||
|
||
|
||
INVALID_POINT_RE = re.compile(
|
||
r"\[\s*([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s+"
|
||
r"([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)\s*\]"
|
||
)
|
||
|
||
|
||
def qi(name: str) -> str:
|
||
return "`" + name.replace("`", "``") + "`"
|
||
|
||
|
||
def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any:
|
||
cur.execute(sql, params)
|
||
row = cur.fetchone()
|
||
return next(iter(row.values())) if row else None
|
||
|
||
|
||
def fetch_all(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
||
cur.execute(sql, params)
|
||
return list(cur.fetchall())
|
||
|
||
|
||
def write_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None):
|
||
rows = list(rows)
|
||
if fieldnames is None:
|
||
seen = set()
|
||
fieldnames = []
|
||
for row in rows:
|
||
for k in row.keys():
|
||
if k not in seen:
|
||
seen.add(k)
|
||
fieldnames.append(k)
|
||
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
||
if not fieldnames:
|
||
return
|
||
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||
w.writeheader()
|
||
for row in rows:
|
||
out = {}
|
||
for k in fieldnames:
|
||
v = row.get(k)
|
||
if isinstance(v, (tuple, list, dict)):
|
||
v = json.dumps(v, ensure_ascii=False, default=str)
|
||
elif isinstance(v, (bytes, bytearray, memoryview)):
|
||
b = bytes(v)
|
||
v = b.hex()
|
||
out[k] = "" if v is None else v
|
||
w.writerow(out)
|
||
|
||
|
||
def begin_read_only(cur, warnings: List[str]):
|
||
try:
|
||
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
||
try:
|
||
cur.execute("START TRANSACTION READ ONLY")
|
||
except Exception as e:
|
||
warnings.append(f"START TRANSACTION READ ONLY failed: {e}")
|
||
cur.execute("START TRANSACTION")
|
||
|
||
|
||
def probe_wkb_fn(cur) -> str:
|
||
for fn in ("ST_AsBinary", "ST_AsWKB", "AsBinary", "AsWKB"):
|
||
try:
|
||
cur.execute(f"SELECT {fn}(geom) AS x FROM chart_area_geometry LIMIT 1")
|
||
cur.fetchone()
|
||
return fn
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("找不到 geometry -> WKB 函数")
|
||
|
||
|
||
def parse_invalid_point(reason: str) -> Optional[Tuple[float, float]]:
|
||
if not reason:
|
||
return None
|
||
m = INVALID_POINT_RE.search(reason)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return float(m.group(1)), float(m.group(2))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def to_bytes(v: Any) -> Optional[bytes]:
|
||
if v is None:
|
||
return None
|
||
if isinstance(v, memoryview):
|
||
return v.tobytes()
|
||
if isinstance(v, bytearray):
|
||
return bytes(v)
|
||
if isinstance(v, bytes):
|
||
return v
|
||
return bytes(v)
|
||
|
||
|
||
def bbox_from_row(row: Dict[str, Any], prefix: str = "") -> Optional[Tuple[float, float, float, float]]:
|
||
keys = (
|
||
prefix + "bbox_min_lon",
|
||
prefix + "bbox_min_lat",
|
||
prefix + "bbox_max_lon",
|
||
prefix + "bbox_max_lat",
|
||
)
|
||
vals = []
|
||
for k in keys:
|
||
try:
|
||
v = float(row[k])
|
||
if not math.isfinite(v):
|
||
return None
|
||
vals.append(v)
|
||
except Exception:
|
||
return None
|
||
return tuple(vals) # type: ignore
|
||
|
||
|
||
def point_close(a: Tuple[float, float], b: Tuple[float, float], tol: float) -> bool:
|
||
return abs(a[0] - b[0]) <= tol and abs(a[1] - b[1]) <= tol
|
||
|
||
|
||
def ring_lists(geom) -> List[Tuple[str, List[Tuple[float, float]]]]:
|
||
out = []
|
||
|
||
def add_poly(poly, pi: int):
|
||
try:
|
||
out.append((f"p{pi}.shell", [(float(c[0]), float(c[1])) for c in poly.exterior.coords]))
|
||
except Exception:
|
||
pass
|
||
for hi, ring in enumerate(poly.interiors):
|
||
try:
|
||
out.append((f"p{pi}.hole{hi}", [(float(c[0]), float(c[1])) for c in ring.coords]))
|
||
except Exception:
|
||
pass
|
||
|
||
if geom.geom_type == "Polygon":
|
||
add_poly(geom, 0)
|
||
elif geom.geom_type == "MultiPolygon":
|
||
for pi, poly in enumerate(geom.geoms):
|
||
add_poly(poly, pi)
|
||
return out
|
||
|
||
|
||
def point_relation_to_area_boundary(
|
||
geom,
|
||
pxy: Tuple[float, float],
|
||
tol: float
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
判断一个目标点与某 Area boundary/ring 的关系。
|
||
"""
|
||
p = Point(pxy)
|
||
vertex_occ = 0
|
||
segment_hits = 0
|
||
segment_interior_hits = 0
|
||
endpoint_hits = 0
|
||
ring_labels = set()
|
||
|
||
for label, coords in ring_lists(geom):
|
||
core = coords[:-1] if len(coords) > 1 and point_close(coords[0], coords[-1], tol) else coords
|
||
for c in core:
|
||
if point_close(c, pxy, tol):
|
||
vertex_occ += 1
|
||
ring_labels.add(label)
|
||
|
||
for i in range(len(coords) - 1):
|
||
a, b = coords[i], coords[i+1]
|
||
try:
|
||
seg = LineString([a, b])
|
||
if seg.distance(p) <= tol:
|
||
segment_hits += 1
|
||
ring_labels.add(label)
|
||
at_a = point_close(a, pxy, tol)
|
||
at_b = point_close(b, pxy, tol)
|
||
if at_a or at_b:
|
||
endpoint_hits += 1
|
||
else:
|
||
segment_interior_hits += 1
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
boundary_distance = float(geom.boundary.distance(p))
|
||
except Exception:
|
||
boundary_distance = None
|
||
|
||
try:
|
||
contains = bool(geom.contains(p))
|
||
except Exception:
|
||
contains = False
|
||
try:
|
||
covers = bool(geom.covers(p))
|
||
except Exception:
|
||
covers = False
|
||
|
||
if vertex_occ > 0:
|
||
relation = "VERTEX"
|
||
elif segment_interior_hits > 0:
|
||
relation = "SEGMENT_INTERIOR"
|
||
elif segment_hits > 0:
|
||
relation = "BOUNDARY"
|
||
elif boundary_distance is not None and boundary_distance <= tol:
|
||
relation = "BOUNDARY_NEAR"
|
||
elif contains:
|
||
relation = "INTERIOR"
|
||
elif covers:
|
||
relation = "COVERS_OTHER"
|
||
else:
|
||
relation = "NONE"
|
||
|
||
return {
|
||
"relation": relation,
|
||
"vertex_occurrences": vertex_occ,
|
||
"segment_hits": segment_hits,
|
||
"segment_interior_hits": segment_interior_hits,
|
||
"endpoint_hits": endpoint_hits,
|
||
"ring_labels": "|".join(sorted(ring_labels)),
|
||
"boundary_distance_deg": boundary_distance,
|
||
"contains_point": int(contains),
|
||
"covers_point": int(covers),
|
||
}
|
||
|
||
|
||
def get_columns(cur, db: str, table: str) -> List[Dict[str, Any]]:
|
||
return fetch_all(cur, """
|
||
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
|
||
FROM information_schema.COLUMNS
|
||
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
|
||
ORDER BY ORDINAL_POSITION
|
||
""", (db, table))
|
||
|
||
|
||
def col_names(cols: List[Dict[str, Any]]) -> List[str]:
|
||
return [str(x["COLUMN_NAME"]) for x in cols]
|
||
|
||
|
||
def find_col(cols: Sequence[str], candidates: Sequence[str]) -> Optional[str]:
|
||
lower = {c.lower(): c for c in cols}
|
||
for cand in candidates:
|
||
if cand.lower() in lower:
|
||
return lower[cand.lower()]
|
||
for cand in candidates:
|
||
for c in cols:
|
||
if cand.lower() in c.lower():
|
||
return c
|
||
return None
|
||
|
||
|
||
def detect_raw_record_link(cur, db: str) -> Dict[str, Any]:
|
||
cols_meta = get_columns(cur, db, "chart_raw_record")
|
||
cols = col_names(cols_meta)
|
||
return {
|
||
"columns": cols_meta,
|
||
"cell_col": find_col(cols, ["cell_id", "cellid"]),
|
||
"record_index_col": find_col(cols, [
|
||
"source_record_index", "record_index", "record_no",
|
||
"record_number", "source_index", "index"
|
||
]),
|
||
"object_class_col": find_col(cols, ["object_class", "class_name", "object"]),
|
||
"payload_cols": [
|
||
c for c in cols
|
||
if any(x in c.lower() for x in (
|
||
"raw", "payload", "data", "blob", "json",
|
||
"hex", "content", "record"
|
||
))
|
||
],
|
||
}
|
||
|
||
|
||
def load_invalid_areas(cur, dataset_id: int, wkb_fn: str) -> List[Dict[str, Any]]:
|
||
cur.execute(f"""
|
||
SELECT
|
||
g.feature_id,
|
||
{wkb_fn}(g.geom) AS geom_wkb,
|
||
f.*,
|
||
c.cell_name,
|
||
c.bbox_min_lon AS cell_bbox_min_lon,
|
||
c.bbox_min_lat AS cell_bbox_min_lat,
|
||
c.bbox_max_lon AS cell_bbox_max_lon,
|
||
c.bbox_max_lat AS cell_bbox_max_lat
|
||
FROM chart_area_geometry g
|
||
JOIN chart_feature f ON f.id=g.feature_id
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s
|
||
ORDER BY g.feature_id
|
||
""", (dataset_id,))
|
||
out = []
|
||
for r in cur:
|
||
try:
|
||
geom = load_wkb(to_bytes(r["geom_wkb"]))
|
||
if not geom.is_valid:
|
||
reason = explain_validity(geom)
|
||
p = parse_invalid_point(reason)
|
||
x = dict(r)
|
||
x["_geom"] = geom
|
||
x["_reason"] = reason
|
||
x["_invalid_point"] = p
|
||
out.append(x)
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
def load_invalid_coverages(cur, dataset_id: int, wkb_fn: str) -> List[Dict[str, Any]]:
|
||
cur.execute(f"""
|
||
SELECT
|
||
v.*,
|
||
{wkb_fn}(v.geom) AS geom_wkb,
|
||
c.cell_name,
|
||
c.bbox_min_lon AS cell_bbox_min_lon,
|
||
c.bbox_min_lat AS cell_bbox_min_lat,
|
||
c.bbox_max_lon AS cell_bbox_max_lon,
|
||
c.bbox_max_lat AS cell_bbox_max_lat
|
||
FROM chart_coverage v
|
||
JOIN chart_cell c ON c.id=v.cell_id
|
||
WHERE v.dataset_id=%s
|
||
ORDER BY v.id
|
||
""", (dataset_id,))
|
||
out = []
|
||
for r in cur:
|
||
try:
|
||
geom = load_wkb(to_bytes(r["geom_wkb"]))
|
||
if not geom.is_valid:
|
||
reason = explain_validity(geom)
|
||
p = parse_invalid_point(reason)
|
||
x = dict(r)
|
||
x["_geom"] = geom
|
||
x["_reason"] = reason
|
||
x["_invalid_point"] = p
|
||
out.append(x)
|
||
except Exception:
|
||
continue
|
||
return out
|
||
|
||
|
||
def load_all_areas_for_cells(
|
||
cur,
|
||
dataset_id: int,
|
||
cell_ids: Sequence[int],
|
||
wkb_fn: str,
|
||
) -> Dict[int, List[Dict[str, Any]]]:
|
||
out = defaultdict(list)
|
||
ids = sorted(set(int(x) for x in cell_ids))
|
||
for pos in range(0, len(ids), 100):
|
||
batch = ids[pos:pos+100]
|
||
ph = ",".join(["%s"] * len(batch))
|
||
sql = f"""
|
||
SELECT
|
||
g.feature_id,
|
||
{wkb_fn}(g.geom) AS geom_wkb,
|
||
f.object_class,
|
||
f.source_record_index,
|
||
f.coverage_id,
|
||
f.geometry_issue,
|
||
f.geometry_repaired,
|
||
f.raw_geotype,
|
||
f.cell_id,
|
||
c.cell_name
|
||
FROM chart_area_geometry g
|
||
JOIN chart_feature f ON f.id=g.feature_id
|
||
JOIN chart_cell c ON c.id=f.cell_id
|
||
WHERE f.dataset_id=%s
|
||
AND f.cell_id IN ({ph})
|
||
ORDER BY f.cell_id, f.id
|
||
"""
|
||
cur.execute(sql, [dataset_id] + batch)
|
||
for r in cur.fetchall():
|
||
try:
|
||
r["_geom"] = load_wkb(to_bytes(r["geom_wkb"]))
|
||
out[int(r["cell_id"])].append(r)
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
|
||
def dump_attributes(cur, feature_ids: Sequence[int], db: str) -> List[Dict[str, Any]]:
|
||
if not feature_ids:
|
||
return []
|
||
|
||
attr_cols = col_names(get_columns(cur, db, "chart_feature_attribute"))
|
||
dict_cols = col_names(get_columns(cur, db, "chart_dictionary_attribute"))
|
||
|
||
feature_col = find_col(attr_cols, ["feature_id"])
|
||
attr_id_col = find_col(attr_cols, ["attribute_id", "attr_id", "dictionary_attribute_id"])
|
||
if not feature_col:
|
||
return []
|
||
|
||
# 为最大兼容性,先原样导出 feature_attribute。
|
||
raw_rows = []
|
||
ids = list(sorted(set(int(x) for x in feature_ids)))
|
||
for pos in range(0, len(ids), 500):
|
||
b = ids[pos:pos+500]
|
||
ph = ",".join(["%s"] * len(b))
|
||
raw_rows.extend(fetch_all(
|
||
cur,
|
||
f"SELECT * FROM chart_feature_attribute WHERE {qi(feature_col)} IN ({ph}) ORDER BY {qi(feature_col)}",
|
||
b
|
||
))
|
||
|
||
# 如果能识别字典主键,则补充 dictionary 字段。
|
||
if attr_id_col:
|
||
dict_pk = find_col(dict_cols, ["id", "attribute_id", "attr_id"])
|
||
if dict_pk:
|
||
dict_ids = sorted({
|
||
int(r[attr_id_col])
|
||
for r in raw_rows
|
||
if r.get(attr_id_col) is not None
|
||
})
|
||
dictionary = {}
|
||
for pos in range(0, len(dict_ids), 500):
|
||
b = dict_ids[pos:pos+500]
|
||
ph = ",".join(["%s"] * len(b))
|
||
for d in fetch_all(
|
||
cur,
|
||
f"SELECT * FROM chart_dictionary_attribute WHERE {qi(dict_pk)} IN ({ph})",
|
||
b
|
||
):
|
||
dictionary[str(d[dict_pk])] = d
|
||
|
||
enriched = []
|
||
for r in raw_rows:
|
||
x = dict(r)
|
||
d = dictionary.get(str(r.get(attr_id_col)))
|
||
if d:
|
||
for k, v in d.items():
|
||
x["dict__" + k] = v
|
||
enriched.append(x)
|
||
return enriched
|
||
|
||
return raw_rows
|
||
|
||
|
||
def dump_raw_records(
|
||
cur,
|
||
db: str,
|
||
invalid_rows: List[Dict[str, Any]],
|
||
raw_link: Dict[str, Any],
|
||
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
||
cell_col = raw_link.get("cell_col")
|
||
idx_col = raw_link.get("record_index_col")
|
||
result_meta = {
|
||
"raw_record_total": int(scalar(cur, "SELECT COUNT(*) FROM chart_raw_record") or 0),
|
||
"cell_col": cell_col,
|
||
"record_index_col": idx_col,
|
||
"exact_match_supported": bool(cell_col and idx_col),
|
||
"exact_match_count": 0,
|
||
"nearby_row_count": 0,
|
||
}
|
||
|
||
if not cell_col or not idx_col:
|
||
# schema 能看,但无法安全猜连接逻辑。
|
||
sample = fetch_all(cur, "SELECT * FROM chart_raw_record LIMIT 50")
|
||
result_meta["fallback_sample_count"] = len(sample)
|
||
return sample, result_meta
|
||
|
||
output = []
|
||
seen = set()
|
||
|
||
for r in invalid_rows:
|
||
cell_id = r.get("cell_id")
|
||
src_idx = r.get("source_record_index")
|
||
if cell_id is None or src_idx is None:
|
||
continue
|
||
|
||
# 精确 + 前后两条,仅用于调查;不假定 record_index 必然等价。
|
||
rs = fetch_all(cur, f"""
|
||
SELECT *,
|
||
CASE WHEN {qi(idx_col)}=%s THEN 1 ELSE 0 END AS _exact_source_index
|
||
FROM chart_raw_record
|
||
WHERE {qi(cell_col)}=%s
|
||
AND {qi(idx_col)} BETWEEN %s AND %s
|
||
ORDER BY {qi(idx_col)}
|
||
""", (src_idx, cell_id, int(src_idx)-2, int(src_idx)+2))
|
||
|
||
for x in rs:
|
||
key = json.dumps(x, sort_keys=True, default=str)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
y = dict(x)
|
||
y["_target_feature_id"] = r.get("feature_id")
|
||
y["_target_cell_name"] = r.get("cell_name")
|
||
y["_target_source_record_index"] = src_idx
|
||
output.append(y)
|
||
result_meta["nearby_row_count"] += 1
|
||
if int(x.get("_exact_source_index") or 0):
|
||
result_meta["exact_match_count"] += 1
|
||
|
||
return output, result_meta
|
||
|
||
|
||
def make_geojson(
|
||
invalid_areas: List[Dict[str, Any]],
|
||
invalid_coverages: List[Dict[str, Any]],
|
||
neighbor_rows: List[Dict[str, Any]],
|
||
path: Path,
|
||
):
|
||
features = []
|
||
|
||
def add_geom(geom, props):
|
||
try:
|
||
features.append({
|
||
"type": "Feature",
|
||
"geometry": mapping(geom),
|
||
"properties": props,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# invalid Area + invalid point
|
||
for r in invalid_areas:
|
||
geom = r["_geom"]
|
||
p = r["_invalid_point"]
|
||
add_geom(geom, {
|
||
"layer": "invalid_area",
|
||
"feature_id": r.get("feature_id"),
|
||
"object_class": r.get("object_class"),
|
||
"cell_name": r.get("cell_name"),
|
||
"source_record_index": r.get("source_record_index"),
|
||
"coverage_id": r.get("coverage_id"),
|
||
"validity": r.get("_reason"),
|
||
})
|
||
if p:
|
||
add_geom(Point(p), {
|
||
"layer": "invalid_point",
|
||
"source": "AREA",
|
||
"feature_id": r.get("feature_id"),
|
||
"cell_name": r.get("cell_name"),
|
||
"validity": r.get("_reason"),
|
||
})
|
||
|
||
# invalid Coverage + invalid point
|
||
for r in invalid_coverages:
|
||
geom = r["_geom"]
|
||
p = r["_invalid_point"]
|
||
add_geom(geom, {
|
||
"layer": "invalid_coverage",
|
||
"coverage_id": r.get("id"),
|
||
"cell_name": r.get("cell_name"),
|
||
"source_record_index": r.get("source_record_index"),
|
||
"validity": r.get("_reason"),
|
||
})
|
||
if p:
|
||
add_geom(Point(p), {
|
||
"layer": "invalid_point",
|
||
"source": "COVERAGE",
|
||
"coverage_id": r.get("id"),
|
||
"cell_name": r.get("cell_name"),
|
||
"validity": r.get("_reason"),
|
||
})
|
||
|
||
# 只加入确实共享 invalid point 的 Area 邻居,避免 GeoJSON 爆大。
|
||
added_neighbors = set()
|
||
for n in neighbor_rows:
|
||
if n.get("relation") not in {"VERTEX", "SEGMENT_INTERIOR", "BOUNDARY", "BOUNDARY_NEAR"}:
|
||
continue
|
||
fid = n.get("neighbor_feature_id")
|
||
geom = n.get("_geom")
|
||
if geom is None:
|
||
continue
|
||
key = (fid, n.get("target_invalid_x"), n.get("target_invalid_y"))
|
||
if key in added_neighbors:
|
||
continue
|
||
added_neighbors.add(key)
|
||
add_geom(geom, {
|
||
"layer": "shared_neighbor_area",
|
||
"feature_id": fid,
|
||
"object_class": n.get("neighbor_object_class"),
|
||
"cell_name": n.get("cell_name"),
|
||
"target_feature_id": n.get("target_feature_id"),
|
||
"relation": n.get("relation"),
|
||
"neighbor_valid": n.get("neighbor_valid"),
|
||
})
|
||
|
||
fc = {"type": "FeatureCollection", "features": features}
|
||
path.write_text(
|
||
json.dumps(fc, ensure_ascii=False, separators=(",", ":"), default=str),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
|
||
def zip_dir(folder: Path) -> Path:
|
||
zpath = folder.with_suffix(".zip")
|
||
with zipfile.ZipFile(zpath, "w", compression=zipfile.ZIP_DEFLATED) as z:
|
||
for p in sorted(folder.rglob("*")):
|
||
if p.is_file():
|
||
z.write(p, p.relative_to(folder.parent))
|
||
return zpath
|
||
|
||
|
||
def parse_args():
|
||
p = argparse.ArgumentParser(description="CM93 invalid geometry source/topology trace v5")
|
||
p.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
||
p.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
||
p.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
||
p.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
||
p.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart"))
|
||
p.add_argument("--dataset-id", type=int, default=1)
|
||
p.add_argument("--point-tolerance", type=float, default=1e-8)
|
||
p.add_argument("--output", default=None)
|
||
return p.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
if not args.password:
|
||
args.password = getpass.getpass("MySQL password: ")
|
||
|
||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
outdir = Path(args.output or f"cm93_invalid_trace_v5_dataset_{args.dataset_id}_{stamp}")
|
||
outdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
report = {
|
||
"version": "v5.0",
|
||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||
"database": args.database,
|
||
"dataset_id": args.dataset_id,
|
||
"read_only": True,
|
||
"pbf": "OUT_OF_SCOPE",
|
||
"scope_note": (
|
||
"CM93JAPAN 为局部数据集。V5 不要求跨未导入海域连续,"
|
||
"只追 invalid Area/Coverage 的源记录和同 Cell 邻接拓扑。"
|
||
),
|
||
"warnings": [],
|
||
}
|
||
|
||
conn = None
|
||
try:
|
||
conn = pymysql.connect(
|
||
host=args.host,
|
||
port=args.port,
|
||
user=args.user,
|
||
password=args.password,
|
||
database=args.database,
|
||
charset="utf8mb4",
|
||
cursorclass=DictCursor,
|
||
autocommit=False,
|
||
connect_timeout=20,
|
||
read_timeout=600,
|
||
write_timeout=30,
|
||
)
|
||
|
||
with conn.cursor() as cur:
|
||
begin_read_only(cur, report["warnings"])
|
||
report["mysql_version"] = scalar(cur, "SELECT VERSION()")
|
||
wkb_fn = probe_wkb_fn(cur)
|
||
report["wkb_fn"] = wkb_fn
|
||
|
||
print("[1/7] 找出 invalid Area / Coverage ...")
|
||
invalid_areas = load_invalid_areas(cur, args.dataset_id, wkb_fn)
|
||
invalid_cov = load_invalid_coverages(cur, args.dataset_id, wkb_fn)
|
||
print(f" Area={len(invalid_areas)} Coverage={len(invalid_cov)}")
|
||
|
||
affected_cells = sorted({
|
||
int(r["cell_id"])
|
||
for r in invalid_areas + invalid_cov
|
||
if r.get("cell_id") is not None
|
||
})
|
||
report["affected_cell_count"] = len(affected_cells)
|
||
|
||
print("[2/7] 加载受影响 Cell 的所有 Area,检查共享 invalid point ...")
|
||
all_areas_by_cell = load_all_areas_for_cells(
|
||
cur, args.dataset_id, affected_cells, wkb_fn
|
||
)
|
||
|
||
neighbor_rows = []
|
||
shared_counter = Counter()
|
||
target_summary = []
|
||
|
||
for target in invalid_areas:
|
||
p = target["_invalid_point"]
|
||
if not p:
|
||
continue
|
||
fid = int(target["feature_id"])
|
||
cell_id = int(target["cell_id"])
|
||
neighbors_at_point = 0
|
||
valid_neighbors_at_point = 0
|
||
invalid_neighbors_at_point = 0
|
||
relation_counts = Counter()
|
||
|
||
for n in all_areas_by_cell.get(cell_id, []):
|
||
nf = int(n["feature_id"])
|
||
if nf == fid:
|
||
continue
|
||
geom = n["_geom"]
|
||
|
||
rel = point_relation_to_area_boundary(
|
||
geom, p, args.point_tolerance
|
||
)
|
||
relation = rel["relation"]
|
||
|
||
if relation in {"VERTEX", "SEGMENT_INTERIOR", "BOUNDARY", "BOUNDARY_NEAR"}:
|
||
neighbors_at_point += 1
|
||
try:
|
||
nvalid = bool(geom.is_valid)
|
||
except Exception:
|
||
nvalid = False
|
||
if nvalid:
|
||
valid_neighbors_at_point += 1
|
||
else:
|
||
invalid_neighbors_at_point += 1
|
||
relation_counts[relation] += 1
|
||
|
||
row = {
|
||
"target_feature_id": fid,
|
||
"target_object_class": target.get("object_class"),
|
||
"target_source_record_index": target.get("source_record_index"),
|
||
"target_invalid_reason": target.get("_reason"),
|
||
"target_invalid_x": p[0],
|
||
"target_invalid_y": p[1],
|
||
"cell_id": cell_id,
|
||
"cell_name": target.get("cell_name"),
|
||
"neighbor_feature_id": nf,
|
||
"neighbor_object_class": n.get("object_class"),
|
||
"neighbor_source_record_index": n.get("source_record_index"),
|
||
"neighbor_coverage_id": n.get("coverage_id"),
|
||
"neighbor_valid": int(nvalid),
|
||
"neighbor_validity_reason": "" if nvalid else explain_validity(geom),
|
||
}
|
||
row.update(rel)
|
||
row["_geom"] = geom
|
||
neighbor_rows.append(row)
|
||
|
||
shared_counter[neighbors_at_point] += 1
|
||
target_summary.append({
|
||
"feature_id": fid,
|
||
"object_class": target.get("object_class"),
|
||
"cell_id": cell_id,
|
||
"cell_name": target.get("cell_name"),
|
||
"source_record_index": target.get("source_record_index"),
|
||
"coverage_id": target.get("coverage_id"),
|
||
"invalid_reason": target.get("_reason"),
|
||
"invalid_x": p[0],
|
||
"invalid_y": p[1],
|
||
"shared_neighbor_count": neighbors_at_point,
|
||
"shared_valid_neighbor_count": valid_neighbors_at_point,
|
||
"shared_invalid_neighbor_count": invalid_neighbors_at_point,
|
||
"neighbor_relations": dict(relation_counts),
|
||
})
|
||
|
||
print(f" shared neighbor rows={len(neighbor_rows)}")
|
||
|
||
print("[3/7] 导出 invalid Feature attributes ...")
|
||
feature_ids = [int(r["feature_id"]) for r in invalid_areas]
|
||
attrs = dump_attributes(cur, feature_ids, args.database)
|
||
write_csv(outdir / "invalid_feature_attributes.csv", attrs)
|
||
|
||
print("[4/7] 追 chart_raw_record / source_record_index ...")
|
||
raw_link = detect_raw_record_link(cur, args.database)
|
||
raw_rows, raw_meta = dump_raw_records(
|
||
cur, args.database, invalid_areas, raw_link
|
||
)
|
||
report["raw_record_link"] = raw_meta
|
||
write_csv(outdir / "chart_raw_record_schema.csv", raw_link["columns"])
|
||
write_csv(outdir / "invalid_raw_record_trace.csv", raw_rows)
|
||
|
||
print("[5/7] 导出 Feature / Coverage 完整元数据 ...")
|
||
feature_export = []
|
||
for r in invalid_areas:
|
||
x = {
|
||
k: v for k, v in r.items()
|
||
if not k.startswith("_") and k != "geom_wkb"
|
||
}
|
||
x["validity_reason"] = r["_reason"]
|
||
p = r["_invalid_point"]
|
||
x["invalid_x"] = p[0] if p else None
|
||
x["invalid_y"] = p[1] if p else None
|
||
feature_export.append(x)
|
||
|
||
coverage_export = []
|
||
for r in invalid_cov:
|
||
x = {
|
||
k: v for k, v in r.items()
|
||
if not k.startswith("_") and k not in {"geom_wkb", "geom"}
|
||
}
|
||
x["validity_reason"] = r["_reason"]
|
||
p = r["_invalid_point"]
|
||
x["invalid_x"] = p[0] if p else None
|
||
x["invalid_y"] = p[1] if p else None
|
||
coverage_export.append(x)
|
||
|
||
write_csv(outdir / "invalid_feature_full_metadata.csv", feature_export)
|
||
write_csv(outdir / "invalid_coverage_full_metadata.csv", coverage_export)
|
||
write_csv(outdir / "invalid_target_neighbor_summary.csv", target_summary)
|
||
|
||
# CSV 不应塞 Shapely object
|
||
neighbor_csv = [
|
||
{k: v for k, v in r.items() if k != "_geom"}
|
||
for r in neighbor_rows
|
||
]
|
||
write_csv(outdir / "invalid_point_shared_area_neighbors.csv", neighbor_csv)
|
||
|
||
print("[6/7] 生成 GeoJSON ...")
|
||
make_geojson(
|
||
invalid_areas, invalid_cov, neighbor_rows,
|
||
outdir / "invalid_geometry_trace.geojson"
|
||
)
|
||
|
||
# 汇总
|
||
target_counts = Counter()
|
||
valid_share = 0
|
||
invalid_share = 0
|
||
no_share = 0
|
||
for t in target_summary:
|
||
n = int(t["shared_neighbor_count"])
|
||
if n == 0:
|
||
no_share += 1
|
||
target_counts["NO_SHARED_AREA_BOUNDARY_AT_INVALID_POINT"] += 1
|
||
else:
|
||
target_counts["HAS_SHARED_AREA_BOUNDARY_AT_INVALID_POINT"] += 1
|
||
if int(t["shared_valid_neighbor_count"]) > 0:
|
||
valid_share += 1
|
||
if int(t["shared_invalid_neighbor_count"]) > 0:
|
||
invalid_share += 1
|
||
|
||
# coverage_id 分布
|
||
feature_cov = Counter(
|
||
"HAS_COVERAGE_ID" if r.get("coverage_id") is not None else "NO_COVERAGE_ID"
|
||
for r in invalid_areas
|
||
)
|
||
|
||
report["summary"] = {
|
||
"invalid_area_count": len(invalid_areas),
|
||
"invalid_coverage_count": len(invalid_cov),
|
||
"affected_cell_count": len(affected_cells),
|
||
"invalid_area_with_shared_area_boundary_at_invalid_point":
|
||
target_counts["HAS_SHARED_AREA_BOUNDARY_AT_INVALID_POINT"],
|
||
"invalid_area_without_shared_area_boundary_at_invalid_point":
|
||
no_share,
|
||
"invalid_area_sharing_point_with_at_least_one_valid_area":
|
||
valid_share,
|
||
"invalid_area_sharing_point_with_at_least_one_invalid_area":
|
||
invalid_share,
|
||
"shared_neighbor_relation_counts": dict(Counter(
|
||
r["relation"] for r in neighbor_rows
|
||
)),
|
||
"invalid_feature_coverage_id_presence": dict(feature_cov),
|
||
"raw_record_trace": raw_meta,
|
||
}
|
||
|
||
print("[7/7] 写报告并压缩 ...")
|
||
(outdir / "invalid_trace_report_v5.json").write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2, default=str),
|
||
encoding="utf-8"
|
||
)
|
||
|
||
lines = []
|
||
lines.append("CM93 -> MySQL invalid Area/Coverage 源记录 + 邻接拓扑追踪 v5")
|
||
lines.append("=" * 88)
|
||
lines.append(f"Generated: {report['generated_at']}")
|
||
lines.append(f"Dataset ID: {args.dataset_id}")
|
||
lines.append(f"MySQL: {report['mysql_version']}")
|
||
lines.append("")
|
||
lines.append("SCOPE")
|
||
lines.append(" CM93JAPAN 为局部数据集;跨未导入海域连续性不作为 FAIL。")
|
||
lines.append(" 本轮不修 geometry,只调查 invalid point 的共享拓扑与 source_record。")
|
||
lines.append("")
|
||
lines.append("INVALID BASELINE")
|
||
lines.append(f" invalid Area: {len(invalid_areas)}")
|
||
lines.append(f" invalid Coverage: {len(invalid_cov)}")
|
||
lines.append(f" affected Cells: {len(affected_cells)}")
|
||
lines.append("")
|
||
lines.append("SHARED AREA TOPOLOGY AT INVALID POINT")
|
||
lines.append(
|
||
f" invalid Area with >=1 other Area boundary at same point: "
|
||
f"{report['summary']['invalid_area_with_shared_area_boundary_at_invalid_point']}"
|
||
)
|
||
lines.append(
|
||
f" invalid Area with no other Area boundary at same point: "
|
||
f"{report['summary']['invalid_area_without_shared_area_boundary_at_invalid_point']}"
|
||
)
|
||
lines.append(
|
||
f" invalid Area sharing point with >=1 VALID Area: "
|
||
f"{report['summary']['invalid_area_sharing_point_with_at_least_one_valid_area']}"
|
||
)
|
||
lines.append(
|
||
f" invalid Area sharing point with >=1 INVALID Area: "
|
||
f"{report['summary']['invalid_area_sharing_point_with_at_least_one_invalid_area']}"
|
||
)
|
||
lines.append("")
|
||
lines.append("NEIGHBOR RELATIONS")
|
||
for k, v in report["summary"]["shared_neighbor_relation_counts"].items():
|
||
lines.append(f" {k:28s} {v}")
|
||
lines.append("")
|
||
lines.append("FEATURE coverage_id")
|
||
for k, v in feature_cov.items():
|
||
lines.append(f" {k:28s} {v}")
|
||
lines.append("")
|
||
lines.append("RAW RECORD TRACE")
|
||
lines.append(f" chart_raw_record rows: {raw_meta['raw_record_total']}")
|
||
lines.append(f" detected cell column: {raw_meta['cell_col']}")
|
||
lines.append(f" detected record index column: {raw_meta['record_index_col']}")
|
||
lines.append(f" exact source-index match supported:{raw_meta['exact_match_supported']}")
|
||
lines.append(f" exact source-index matches: {raw_meta['exact_match_count']}")
|
||
lines.append(f" exported nearby raw rows: {raw_meta['nearby_row_count']}")
|
||
lines.append("")
|
||
lines.append("OUTPUT OF SPECIAL INTEREST")
|
||
lines.append(" invalid_target_neighbor_summary.csv")
|
||
lines.append(" invalid_point_shared_area_neighbors.csv")
|
||
lines.append(" invalid_feature_full_metadata.csv")
|
||
lines.append(" invalid_feature_attributes.csv")
|
||
lines.append(" chart_raw_record_schema.csv")
|
||
lines.append(" invalid_raw_record_trace.csv")
|
||
lines.append(" invalid_geometry_trace.geojson")
|
||
lines.append("")
|
||
lines.append("INTERPRETATION")
|
||
lines.append(
|
||
" 若 invalid point 被多个正常 Area 共享,优先怀疑共享拓扑/ring assembly 表达,"
|
||
"而不是孤立坐标损坏。"
|
||
)
|
||
lines.append(
|
||
" 若内部 TRUE_SEGMENT_CROSSING 没有任何邻接对象共享该点,"
|
||
"则更值得追 source_record / edge ordering。"
|
||
)
|
||
lines.append(
|
||
" chart_raw_record 若不能按 cell_id + source_record_index 直接关联,"
|
||
"说明 DB 当前可能没有保留足够原始 topology,需要回到 CM93 parser/importer 调查。"
|
||
)
|
||
lines.append("")
|
||
lines.append("NO DATABASE WRITE / NO GEOMETRY REPAIR / NO PBF")
|
||
|
||
(outdir / "invalid_trace_summary_v5.txt").write_text(
|
||
"\n".join(lines), encoding="utf-8"
|
||
)
|
||
|
||
conn.rollback()
|
||
|
||
zpath = zip_dir(outdir)
|
||
print("")
|
||
print("完成。")
|
||
print(f"Output: {outdir.resolve()}")
|
||
print(f"ZIP: {zpath.resolve()}")
|
||
print("把 ZIP 整包上传给 ChatGPT。")
|
||
|
||
except KeyboardInterrupt:
|
||
if conn:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
print("\n用户中断。")
|
||
sys.exit(130)
|
||
except Exception:
|
||
if conn:
|
||
try:
|
||
conn.rollback()
|
||
except Exception:
|
||
pass
|
||
tb = traceback.format_exc()
|
||
print(tb)
|
||
try:
|
||
(outdir / "fatal_error.txt").write_text(tb, encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
sys.exit(1)
|
||
finally:
|
||
if conn:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|