Files
CM95/cm93_mysql_invalid_trace_v5.py
2026-09-02 09:50:12 +08:00

1042 lines
37 KiB
Python
Raw Permalink Blame History

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