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

978 lines
35 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 无效 Area / Coverage 深度分类审计
Version: v4.0
背景:
- Dataset ID=1 为日本沿海局部 CM93 数据子集,不是完整 CM93。
- V3 已确认:
* Point 113346:全部有效
* Line 121773:全部有效
* Sounding 275700:全部有效
* Area 81080:52 个 Shapely invalid
* Coverage 1156:3 个 Shapely invalid
* feature ↔ geometry table mapping issue = 0
* raw_geotype=16 / META 的 4406 条为 EXPECTED_PARENT_NO_GEOMETRY
本脚本目标:
1. 重新找出 invalid Area / Coverage;
2. 对每一个 Shapely 报告的 invalidity point 做局部几何分析;
3. 区分:
- invalid 点本身位于 Cell 边界;
- geometry 只是“整体碰 Cell 边界”,但 invalid 点在内部;
- 重复顶点自接触;
- 线段内部真正交叉;
- shell / hole / ring 结构问题;
- Interior disconnected;
4. 找出多个 Feature 共享同一 invalid point 的簇;
5. 找出 Area 与 Coverage 在同一 Cell、同一 invalid point 的关联;
6. 输出当前数据库中 topology/edge/node/primitive 等相关 schema 线索,供下一阶段追 importer。
7. 全程只读,不修 geometry,不改 MySQL,不做 PBF。
重要:
“当前 Dataset 不是完整 CM93”只影响跨未导入海域的连续性判断。
一个已经生成的 Polygon 如果在自身内部发生真正 segment crossing,
仍属于需要调查的内在 geometry 问题。
但如果 invalidity point 正好位于 Cell 裁切边界、并被多个对象/coverage 共用,
更可能是 Cell clipping / shared topology / ring assembly 类问题,不能直接归为原始数据损坏。
依赖:
pip install pymysql shapely
运行:
python cm93_mysql_invalid_area_audit_v4.py ^
--host 127.0.0.1 ^
--port 3306 ^
--user root ^
--database cm93_chart ^
--dataset-id 1
输出目录会自动压缩成 zip,直接把 zip 发给 ChatGPT。
"""
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
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)
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}")
try:
cur.execute("START TRANSACTION")
except Exception:
pass
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 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 vals[0], vals[1], vals[2], vals[3]
def point_cell_edge_context(
p: Optional[Tuple[float, float]],
bbox: Optional[Tuple[float, float, float, float]],
tol: float,
) -> Tuple[bool, str, Optional[float]]:
if not p or not bbox:
return False, "", None
x, y = p
minx, miny, maxx, maxy = bbox
d = {
"WEST": abs(x - minx),
"EAST": abs(x - maxx),
"SOUTH": abs(y - miny),
"NORTH": abs(y - maxy),
}
edge, dist = min(d.items(), key=lambda kv: kv[1])
return dist <= tol, edge if dist <= tol else "", dist
def point_feature_bbox_edge_context(
p: Optional[Tuple[float, float]],
bbox: Optional[Tuple[float, float, float, float]],
tol: float,
) -> Tuple[bool, str, Optional[float]]:
return point_cell_edge_context(p, bbox, tol)
def ring_coordinate_lists(geom) -> List[Tuple[str, int, List[Tuple[float, float]]]]:
out = []
def add_polygon(poly, poly_index: int):
try:
out.append((f"poly{poly_index}.shell", 0, [(float(x), float(y)) for x, y, *rest in poly.exterior.coords]))
except Exception:
pass
for i, ring in enumerate(poly.interiors):
try:
out.append((f"poly{poly_index}.hole", i, [(float(x), float(y)) for x, y, *rest in ring.coords]))
except Exception:
pass
if geom.geom_type == "Polygon":
add_polygon(geom, 0)
elif geom.geom_type == "MultiPolygon":
for pi, poly in enumerate(geom.geoms):
add_polygon(poly, pi)
return out
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 analyze_invalid_point_on_rings(
geom,
invalid_point: Optional[Tuple[float, float]],
tol: float,
) -> Dict[str, Any]:
result = {
"polygon_count": 0,
"ring_count": 0,
"hole_count": 0,
"total_vertices": 0,
"invalid_point_vertex_occurrences": 0,
"invalid_point_distinct_ring_hits": 0,
"invalid_point_segment_hits": 0,
"invalid_point_segment_interior_hits": 0,
"invalid_point_endpoint_hits": 0,
"repeated_nonconsecutive_vertex_count": 0,
"zero_length_segment_count": 0,
"mechanism": "UNRESOLVED",
"ring_hit_labels": "",
}
if geom.geom_type == "Polygon":
result["polygon_count"] = 1
elif geom.geom_type == "MultiPolygon":
result["polygon_count"] = len(geom.geoms)
else:
return result
rings = ring_coordinate_lists(geom)
result["ring_count"] = len(rings)
result["hole_count"] = sum(1 for label, _, _ in rings if ".hole" in label)
ring_hits = set()
vertex_occurrences = 0
segment_hits = 0
segment_interior_hits = 0
endpoint_hits = 0
repeated_nonconsecutive = 0
zero_segments = 0
pgeom = Point(invalid_point) if invalid_point else None
for label, ring_index, coords in rings:
result["total_vertices"] += len(coords)
# 去掉 ring 最后一个闭合重复点后做“非连续重复顶点”统计。
core = coords[:-1] if len(coords) > 1 and point_close(coords[0], coords[-1], tol) else coords[:]
seen_positions: Dict[Tuple[int, int], List[int]] = defaultdict(list)
scale = 1.0 / max(tol, 1e-12)
for i, c in enumerate(core):
key = (round(c[0] * scale), round(c[1] * scale))
seen_positions[key].append(i)
for positions in seen_positions.values():
if len(positions) > 1:
# 相邻重复点不归到 nonconsecutive;它们通常会形成 zero segment。
has_nonconsecutive = False
for i in range(len(positions)):
for j in range(i+1, len(positions)):
if abs(positions[j] - positions[i]) > 1:
has_nonconsecutive = True
break
if has_nonconsecutive:
break
if has_nonconsecutive:
repeated_nonconsecutive += 1
if invalid_point:
for c in core:
if point_close(c, invalid_point, tol):
vertex_occurrences += 1
ring_hits.add(f"{label}[{ring_index}]")
for i in range(len(coords)-1):
a = coords[i]
b = coords[i+1]
if point_close(a, b, tol):
zero_segments += 1
if not invalid_point:
continue
try:
seg = LineString([a, b])
if seg.distance(pgeom) <= tol:
segment_hits += 1
ring_hits.add(f"{label}[{ring_index}]")
at_a = point_close(a, invalid_point, tol)
at_b = point_close(b, invalid_point, tol)
if at_a or at_b:
endpoint_hits += 1
else:
segment_interior_hits += 1
except Exception:
pass
result["invalid_point_vertex_occurrences"] = vertex_occurrences
result["invalid_point_distinct_ring_hits"] = len(ring_hits)
result["invalid_point_segment_hits"] = segment_hits
result["invalid_point_segment_interior_hits"] = segment_interior_hits
result["invalid_point_endpoint_hits"] = endpoint_hits
result["repeated_nonconsecutive_vertex_count"] = repeated_nonconsecutive
result["zero_length_segment_count"] = zero_segments
result["ring_hit_labels"] = "|".join(sorted(ring_hits))
# 机制分类:只描述几何表现,不直接断言根因。
if not invalid_point:
result["mechanism"] = "NO_INVALID_POINT_FROM_VALIDATOR"
elif segment_interior_hits >= 2 and vertex_occurrences == 0:
result["mechanism"] = "TRUE_SEGMENT_CROSSING"
elif vertex_occurrences >= 2:
result["mechanism"] = "REPEATED_VERTEX_SELF_TOUCH"
elif vertex_occurrences >= 1 and segment_interior_hits >= 1:
result["mechanism"] = "VERTEX_TOUCHES_NONADJACENT_SEGMENT"
elif len(ring_hits) >= 2:
result["mechanism"] = "MULTI_RING_TOUCH_OR_CROSS"
elif segment_hits >= 3:
result["mechanism"] = "MULTI_SEGMENT_TOUCH"
elif segment_hits >= 2:
result["mechanism"] = "TWO_SEGMENT_INTERACTION"
else:
result["mechanism"] = "UNRESOLVED_LOCAL_TOPOLOGY"
return result
def validator_reason_category(reason: str) -> str:
s = (reason or "").lower()
if "ring self-intersection" in s:
return "RING_SELF_INTERSECTION"
if "self-intersection" in s:
return "SELF_INTERSECTION"
if "interior is disconnected" in s:
return "INTERIOR_DISCONNECTED"
if "hole lies outside shell" in s:
return "HOLE_OUTSIDE_SHELL"
if "nested shells" in s:
return "NESTED_SHELLS"
if "nested holes" in s:
return "NESTED_HOLES"
if "too few points" in s:
return "TOO_FEW_POINTS"
return "OTHER_INVALID_GEOMETRY"
def scan_invalid_geometries(
cur,
*,
table: str,
kind: str,
dataset_id: int,
wkb_fn: str,
chunk_size: int,
point_tol: float,
edge_tol: float,
) -> List[Dict[str, Any]]:
last_id = 0
invalids = []
id_col = "feature_id" if kind == "AREA" else "id"
while True:
if kind == "AREA":
sql = f"""
SELECT
g.feature_id AS row_id,
g.feature_id,
NULL AS coverage_id,
{wkb_fn}(g.geom) AS geom_wkb,
f.object_class,
f.raw_geotype,
f.geometry_type,
f.geometry_issue,
f.geometry_repaired,
f.source_record_index,
f.cell_id,
c.cell_name,
f.bbox_min_lon, f.bbox_min_lat, f.bbox_max_lon, f.bbox_max_lat,
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
AND g.feature_id>%s
ORDER BY g.feature_id
LIMIT %s
"""
else:
sql = f"""
SELECT
v.id AS row_id,
NULL AS feature_id,
v.id AS coverage_id,
{wkb_fn}(v.geom) AS geom_wkb,
'COVERAGE' AS object_class,
NULL AS raw_geotype,
'COVERAGE' AS geometry_type,
v.geometry_issue,
v.geometry_repaired,
v.source_record_index,
v.cell_id,
c.cell_name,
NULL AS bbox_min_lon, NULL AS bbox_min_lat,
NULL AS bbox_max_lon, NULL AS bbox_max_lat,
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
AND v.id>%s
ORDER BY v.id
LIMIT %s
"""
cur.execute(sql, (dataset_id, last_id, chunk_size))
batch = list(cur.fetchall())
if not batch:
break
for r in batch:
last_id = int(r["row_id"])
wkb = r["geom_wkb"]
if isinstance(wkb, memoryview):
wkb = wkb.tobytes()
elif isinstance(wkb, bytearray):
wkb = bytes(wkb)
try:
geom = load_wkb(wkb)
except Exception:
continue
try:
valid = bool(geom.is_valid)
except Exception:
continue
if valid:
continue
reason = explain_validity(geom)
p = parse_invalid_point(reason)
cb = bbox_from_row(r, "cell_")
fb = bbox_from_row(r)
on_cell_edge, cell_edge, cell_edge_dist = point_cell_edge_context(p, cb, edge_tol)
on_feature_bbox_edge, feature_bbox_edge, feature_bbox_edge_dist = point_feature_bbox_edge_context(
p, fb, edge_tol
)
local = analyze_invalid_point_on_rings(geom, p, point_tol)
geom_bounds = tuple(float(v) for v in geom.bounds)
row = {
"kind": kind,
"feature_id": r.get("feature_id"),
"coverage_id": r.get("coverage_id"),
"object_class": r.get("object_class"),
"cell_id": r.get("cell_id"),
"cell_name": r.get("cell_name"),
"source_record_index": r.get("source_record_index"),
"raw_geotype": r.get("raw_geotype"),
"geometry_type": r.get("geometry_type"),
"db_geometry_issue": r.get("geometry_issue"),
"db_geometry_repaired": r.get("geometry_repaired"),
"validator_category": validator_reason_category(reason),
"validator_reason": reason,
"invalid_x": p[0] if p else None,
"invalid_y": p[1] if p else None,
"cell_bbox": cb,
"feature_bbox": fb,
"geometry_bounds": geom_bounds,
"invalid_point_on_cell_edge": int(on_cell_edge),
"cell_edge": cell_edge,
"cell_edge_distance_deg": cell_edge_dist,
"invalid_point_on_feature_bbox_edge": int(on_feature_bbox_edge),
"feature_bbox_edge": feature_bbox_edge,
"feature_bbox_edge_distance_deg": feature_bbox_edge_dist,
}
row.update(local)
invalids.append(row)
print(f" {kind:<8} scanned_to={last_id} invalid_found={len(invalids)}")
return invalids
def rounded_point_key(row: Dict[str, Any], decimals: int = 7) -> Optional[Tuple[float, float]]:
try:
x = float(row["invalid_x"])
y = float(row["invalid_y"])
if math.isfinite(x) and math.isfinite(y):
return round(x, decimals), round(y, decimals)
except Exception:
pass
return None
def build_shared_point_groups(all_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
groups = defaultdict(list)
for r in all_rows:
k = rounded_point_key(r)
if k is not None:
groups[k].append(r)
out = []
for p, rs in groups.items():
if len(rs) < 2:
continue
kinds = Counter(r["kind"] for r in rs)
cells = sorted({str(r.get("cell_name") or "") for r in rs})
objs = Counter(str(r.get("object_class") or "") for r in rs)
out.append({
"invalid_x": p[0],
"invalid_y": p[1],
"record_count": len(rs),
"area_count": kinds.get("AREA", 0),
"coverage_count": kinds.get("COVERAGE", 0),
"cells": "|".join(cells),
"object_classes": "|".join(f"{k}:{v}" for k, v in objs.most_common()),
"cell_edge_records": sum(int(r.get("invalid_point_on_cell_edge") or 0) for r in rs),
"feature_ids": "|".join(str(r["feature_id"]) for r in rs if r.get("feature_id") is not None),
"coverage_ids": "|".join(str(r["coverage_id"]) for r in rs if r.get("coverage_id") is not None),
})
out.sort(key=lambda r: (-r["record_count"], r["invalid_x"], r["invalid_y"]))
return out
def mark_area_coverage_links(
areas: List[Dict[str, Any]],
coverages: List[Dict[str, Any]],
decimals: int = 7,
):
cov_index = defaultdict(list)
for c in coverages:
p = rounded_point_key(c, decimals)
if p is not None:
cov_index[(str(c.get("cell_name")), p)].append(c)
for a in areas:
p = rounded_point_key(a, decimals)
matches = cov_index.get((str(a.get("cell_name")), p), []) if p is not None else []
a["matches_invalid_coverage_same_cell_point"] = int(bool(matches))
a["matching_coverage_ids"] = "|".join(
str(x.get("coverage_id")) for x in matches
)
def schema_topology_inventory(cur, database: str) -> List[Dict[str, Any]]:
keywords = (
"edge", "node", "vector", "primitive", "segment",
"geom", "geometry", "coverage", "ring", "topolog"
)
rs = fetch_all(cur, """
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s
ORDER BY TABLE_NAME, ORDINAL_POSITION
""", (database,))
out = []
for r in rs:
blob = (r["TABLE_NAME"] + " " + r["COLUMN_NAME"]).lower()
if any(k in blob for k in keywords):
out.append(r)
return out
def table_inventory(cur, database: str) -> List[Dict[str, Any]]:
return fetch_all(cur, """
SELECT TABLE_NAME, TABLE_ROWS, ENGINE, TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA=%s
ORDER BY TABLE_NAME
""", (database,))
def classify_for_next_step(r: Dict[str, Any]) -> str:
"""
这是“调查优先级/表现分类”,不是最终根因。
"""
vc = r.get("validator_category")
mechanism = r.get("mechanism")
on_edge = bool(r.get("invalid_point_on_cell_edge"))
coverage_link = bool(r.get("matches_invalid_coverage_same_cell_point"))
if vc == "INTERIOR_DISCONNECTED":
return "INTERNAL_RING_STRUCTURE_NEEDS_REVIEW"
if on_edge and coverage_link:
return "CELL_BOUNDARY_SHARED_WITH_COVERAGE"
if on_edge and mechanism in {
"REPEATED_VERTEX_SELF_TOUCH",
"VERTEX_TOUCHES_NONADJACENT_SEGMENT",
"MULTI_RING_TOUCH_OR_CROSS",
"MULTI_SEGMENT_TOUCH",
"TWO_SEGMENT_INTERACTION",
}:
return "CELL_BOUNDARY_TOPOLOGY_CANDIDATE"
if on_edge and mechanism == "TRUE_SEGMENT_CROSSING":
return "CELL_BOUNDARY_SEGMENT_CROSSING"
if not on_edge and mechanism == "TRUE_SEGMENT_CROSSING":
return "INTERNAL_TRUE_SEGMENT_CROSSING"
if not on_edge and mechanism == "REPEATED_VERTEX_SELF_TOUCH":
return "INTERNAL_REPEATED_VERTEX_SELF_TOUCH"
if not on_edge:
return "INTERNAL_INVALID_GEOMETRY_NEEDS_REVIEW"
return "BOUNDARY_INVALID_GEOMETRY_NEEDS_REVIEW"
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 Area/Coverage deep audit v4")
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("--chunk-size", type=int, default=2000)
p.add_argument(
"--point-tolerance",
type=float,
default=1e-8,
help="invalid point 与 ring 顶点/segment 的比较容差(degree)"
)
p.add_argument(
"--cell-edge-tolerance",
type=float,
default=5e-6,
help="invalidity point 是否位于 Cell bbox 边界的容差(degree)"
)
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_area_audit_v4_dataset_{args.dataset_id}_{stamp}")
outdir.mkdir(parents=True, exist_ok=True)
report = {
"version": "v4.0",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"database": args.database,
"dataset_id": args.dataset_id,
"read_only": True,
"pbf": "OUT_OF_SCOPE",
"dataset_scope_note": (
"CM93JAPAN 只是局部子集。跨未导入海域连续性不作为 FAIL;"
"本轮只深挖已经生成的 invalid Polygon/Coverage 自身。"
),
"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/6] 深扫 invalid Area ...")
areas = scan_invalid_geometries(
cur,
table="chart_area_geometry",
kind="AREA",
dataset_id=args.dataset_id,
wkb_fn=wkb_fn,
chunk_size=args.chunk_size,
point_tol=args.point_tolerance,
edge_tol=args.cell_edge_tolerance,
)
print("[2/6] 深扫 invalid Coverage ...")
coverages = scan_invalid_geometries(
cur,
table="chart_coverage",
kind="COVERAGE",
dataset_id=args.dataset_id,
wkb_fn=wkb_fn,
chunk_size=args.chunk_size,
point_tol=args.point_tolerance,
edge_tol=args.cell_edge_tolerance,
)
print("[3/6] 建立 Area ↔ Coverage 同点关联 ...")
mark_area_coverage_links(areas, coverages)
for r in areas:
r["next_step_class"] = classify_for_next_step(r)
for r in coverages:
r["matches_invalid_coverage_same_cell_point"] = 0
r["matching_coverage_ids"] = ""
r["next_step_class"] = classify_for_next_step(r)
all_invalid = areas + coverages
shared_groups = build_shared_point_groups(all_invalid)
print("[4/6] 导出 schema/topology 线索 ...")
topology_schema = schema_topology_inventory(cur, args.database)
tables = table_inventory(cur, args.database)
print("[5/6] 汇总分类 ...")
validator_counts = Counter(r["validator_category"] for r in all_invalid)
mechanism_counts = Counter(r["mechanism"] for r in all_invalid)
next_counts = Counter(r["next_step_class"] for r in all_invalid)
area_next_counts = Counter(r["next_step_class"] for r in areas)
cov_next_counts = Counter(r["next_step_class"] for r in coverages)
summary = {
"invalid_area_count": len(areas),
"invalid_coverage_count": len(coverages),
"invalid_total": len(all_invalid),
"invalid_point_on_cell_edge_total": sum(int(r["invalid_point_on_cell_edge"]) for r in all_invalid),
"invalid_point_internal_total": sum(1 for r in all_invalid if not r["invalid_point_on_cell_edge"]),
"area_invalid_point_on_cell_edge": sum(int(r["invalid_point_on_cell_edge"]) for r in areas),
"area_invalid_point_internal": sum(1 for r in areas if not r["invalid_point_on_cell_edge"]),
"coverage_invalid_point_on_cell_edge": sum(int(r["invalid_point_on_cell_edge"]) for r in coverages),
"area_matches_invalid_coverage_same_cell_point": sum(
int(r.get("matches_invalid_coverage_same_cell_point") or 0)
for r in areas
),
"validator_categories": dict(validator_counts),
"mechanisms": dict(mechanism_counts),
"next_step_classes": dict(next_counts),
"area_next_step_classes": dict(area_next_counts),
"coverage_next_step_classes": dict(cov_next_counts),
"shared_invalid_point_group_count": len(shared_groups),
}
report["summary"] = summary
write_csv(outdir / "invalid_areas_deep_classified.csv", areas)
write_csv(outdir / "invalid_coverages_deep_classified.csv", coverages)
write_csv(outdir / "invalid_all_deep_classified.csv", all_invalid)
write_csv(outdir / "shared_invalid_point_groups.csv", shared_groups)
write_csv(outdir / "topology_schema_inventory.csv", topology_schema)
write_csv(outdir / "database_table_inventory.csv", tables)
write_csv(
outdir / "validator_category_counts.csv",
[{"category": k, "count": v} for k, v in validator_counts.most_common()],
["category", "count"]
)
write_csv(
outdir / "local_mechanism_counts.csv",
[{"mechanism": k, "count": v} for k, v in mechanism_counts.most_common()],
["mechanism", "count"]
)
write_csv(
outdir / "next_step_class_counts.csv",
[{"next_step_class": k, "count": v} for k, v in next_counts.most_common()],
["next_step_class", "count"]
)
# 重点样本:每一 next_step_class 最多 10 条
samples = []
sc = Counter()
for r in sorted(
all_invalid,
key=lambda x: (
x.get("next_step_class", ""),
str(x.get("cell_name", "")),
int(x.get("feature_id") or x.get("coverage_id") or 0)
)
):
c = r["next_step_class"]
if sc[c] < 10:
samples.append(r)
sc[c] += 1
write_csv(outdir / "deep_classification_samples.csv", samples)
(outdir / "invalid_area_audit_report_v4.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str),
encoding="utf-8"
)
lines = []
lines.append("CM93 -> MySQL Invalid Area/Coverage 深度分类审计 v4")
lines.append("=" * 82)
lines.append(f"Generated: {report['generated_at']}")
lines.append(f"Database: {args.database}")
lines.append(f"Dataset ID: {args.dataset_id}")
lines.append(f"MySQL: {report['mysql_version']}")
lines.append("")
lines.append("DATASET SCOPE")
lines.append(" 当前 CM93JAPAN 是局部数据,不要求与未导入海域连续。")
lines.append(" 但本轮检查的是已生成 Polygon 本身的自交/自接触机制。")
lines.append("")
lines.append("COUNTS")
lines.append(f" invalid Area: {len(areas)}")
lines.append(f" invalid Coverage: {len(coverages)}")
lines.append(f" total: {len(all_invalid)}")
lines.append("")
lines.append("INVALID POINT LOCATION")
lines.append(
f" Area invalid point ON cell edge: "
f"{summary['area_invalid_point_on_cell_edge']}"
)
lines.append(
f" Area invalid point INTERNAL: "
f"{summary['area_invalid_point_internal']}"
)
lines.append(
f" Coverage invalid point ON cell edge:"
f" {summary['coverage_invalid_point_on_cell_edge']}"
)
lines.append("")
lines.append("AREA ↔ COVERAGE")
lines.append(
f" Area same cell + same invalid point as invalid Coverage: "
f"{summary['area_matches_invalid_coverage_same_cell_point']}"
)
lines.append("")
lines.append("VALIDATOR CATEGORY")
for k, v in validator_counts.most_common():
lines.append(f" {k:42s} {v}")
lines.append("")
lines.append("LOCAL MECHANISM")
for k, v in mechanism_counts.most_common():
lines.append(f" {k:42s} {v}")
lines.append("")
lines.append("NEXT-STEP CLASS (表现分类,不是最终根因)")
for k, v in next_counts.most_common():
lines.append(f" {k:42s} {v}")
lines.append("")
lines.append("SHARED INVALID POINT GROUPS")
lines.append(f" groups with >=2 invalid records: {len(shared_groups)}")
lines.append("")
lines.append("INTERPRETATION RULES")
lines.append(
" CELL_BOUNDARY_SHARED_WITH_COVERAGE / CELL_BOUNDARY_TOPOLOGY_CANDIDATE "
"优先调查 Cell clipping / shared topology / importer ring assembly。"
)
lines.append(
" INTERNAL_TRUE_SEGMENT_CROSSING 优先调查源拓扑与 importer edge/ring 顺序。"
)
lines.append(
" INTERNAL_REPEATED_VERTEX_SELF_TOUCH 需要判断 CM93 是否允许 touch-ring 表达,"
"以及 importer 是否应输出 MultiPolygon/拆环。"
)
lines.append(
" 本脚本不执行 make_valid、buffer(0)、闭环、补点、删点或任何数据库写入。"
)
lines.append("")
lines.append("PBF: OUT OF SCOPE / NOT STARTED")
(outdir / "invalid_area_audit_summary_v4.txt").write_text(
"\n".join(lines),
encoding="utf-8"
)
conn.rollback()
print("[6/6] 压缩输出 ...")
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()