#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CM93 -> MySQL Dataset ID=1 实体空间 Geometry 只读审计 Version: v3.0 目的: 1. 审计已经真正写入 MySQL 的 Point / Line / Area / Sounding / Coverage geometry。 2. 不再把 raw_geotype=16 的正常 META parent 当作 geometry 错误。 3. 检查单个 geometry 的“内在有效性”,不要求当前日本局部 Dataset 与未导入海域连续。 4. 不修改数据库,不修 geometry,不做 PBF。 特别边界规则: 当前 CM93JAPAN 只是 CM93 的日本沿海局部子集,并非完整 CM93。 因此: - 不做跨 Dataset 外边界连续性 FAIL 判定; - 不因 Line 在 Cell/Dataset 边缘终止而判坏; - 不因缺少未导入邻区对象而判坏; - 对靠近 Cell/Dataset 边界的异常只增加 boundary_context 提示,不自动归因于 importer。 依赖: pip install pymysql shapely 示例: python cm93_mysql_spatial_audit_v3.py ^ --host 127.0.0.1 ^ --port 3306 ^ --user root ^ --database cm93_chart ^ --dataset-id 1 密码缺省时交互输入。 输出: - spatial_audit_summary.txt - spatial_audit_report.json - feature_geometry_mapping_issues.csv - intrinsic_geometry_issues.csv - intrinsic_issue_category_counts.csv - invalid_geometry_reason_counts.csv - geometry_stats_by_kind.csv - issue_object_class_counts.csv - issue_cell_counts.csv - issue_samples.csv - sounding_feature_bbox_issues.csv - coverage_geometry_issues.csv 并自动生成同名 zip。 安全: - SQL 仅包含 SELECT / SHOW / SET TRANSACTION READ ONLY - 无 INSERT / UPDATE / DELETE / DDL """ from __future__ import annotations import argparse import csv import getpass import json import math import os import sys import traceback import zipfile from collections import Counter, defaultdict from dataclasses import dataclass, asdict from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple try: import pymysql from pymysql.cursors import DictCursor except ImportError: print("缺少 pymysql。请执行:pip install pymysql shapely") sys.exit(2) try: import shapely 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) try: from shapely.validation import explain_validity except Exception: def explain_validity(g): return "invalid" except ImportError: print("缺少 shapely。请执行:pip install pymysql shapely") sys.exit(2) EXPECTED_DATASET1 = { "cells": 784, "features": 322465, "attributes": 405153, "sounding_points": 275700, } EXPECTED_GEOMETRY_ISSUE_INTERPRETATION = { "raw_geotype_16_expected_parent_no_geometry": 4406, } def qi(name: str) -> str: return "`" + name.replace("`", "``") + "`" def finite(v: Optional[float]) -> bool: return v is not None and math.isfinite(float(v)) def safe_float(v: Any) -> Optional[float]: if v is None: return None try: x = float(v) return x if math.isfinite(x) else None except Exception: return None def bbox_tuple(row: Dict[str, Any], prefix: str = "") -> Optional[Tuple[float, float, float, float]]: vals = [ safe_float(row.get(prefix + "bbox_min_lon")), safe_float(row.get(prefix + "bbox_min_lat")), safe_float(row.get(prefix + "bbox_max_lon")), safe_float(row.get(prefix + "bbox_max_lat")), ] if any(v is None for v in vals): return None return vals[0], vals[1], vals[2], vals[3] def bbox_close(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float], tol: float) -> bool: return all(abs(float(x) - float(y)) <= tol for x, y in zip(a, b)) def touches_bbox_edge(inner: Optional[Tuple[float, float, float, float]], outer: Optional[Tuple[float, float, float, float]], tol: float) -> bool: if not inner or not outer: return False return ( abs(inner[0] - outer[0]) <= tol or abs(inner[1] - outer[1]) <= tol or abs(inner[2] - outer[2]) <= tol or abs(inner[3] - outer[3]) <= tol ) def bbox_in_lonlat_range(b: Optional[Tuple[float, float, float, float]]) -> bool: if not b: return False minx, miny, maxx, maxy = b return ( -180.0 <= minx <= 180.0 and -180.0 <= maxx <= 180.0 and -90.0 <= miny <= 90.0 and -90.0 <= maxy <= 90.0 and minx <= maxx and miny <= maxy ) def write_csv(path: Path, rows: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None): rows = list(rows) path.parent.mkdir(parents=True, exist_ok=True) if fieldnames is None: seen = set() fieldnames = [] for r in rows: for k in r.keys(): if k not in seen: seen.add(k) fieldnames.append(k) with path.open("w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=fieldnames or [], extrasaction="ignore") if fieldnames: w.writeheader() for r in rows: w.writerow({k: "" if r.get(k) is None else r.get(k) for k in fieldnames}) def scalar(cur, sql: str, params: Sequence[Any] = ()) -> Any: cur.execute(sql, params) row = cur.fetchone() if not row: return None return next(iter(row.values())) def rows(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]: cur.execute(sql, params) return list(cur.fetchall()) 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 不支持/失败:{e}") try: cur.execute("START TRANSACTION READ ONLY") except Exception as e: warnings.append(f"START TRANSACTION READ ONLY 不支持/失败:{e}") try: cur.execute("START TRANSACTION") except Exception: pass def probe_spatial_functions(cur) -> Dict[str, Optional[str]]: """ 探测把 MySQL/MariaDB geometry 转成标准 WKB 的函数和 SRID 函数。 不使用 ST_IsValid,实际 validity 统一交给 Shapely,避免 DB 方言差异。 """ result = {"wkb_fn": None, "srid_fn": None} for fn in ["ST_AsBinary", "ST_AsWKB", "AsBinary", "AsWKB"]: try: cur.execute( f"SELECT {fn}(geom) AS x " f"FROM chart_point_geometry LIMIT 1" ) cur.fetchone() result["wkb_fn"] = fn break except Exception: continue for fn in ["ST_SRID", "SRID"]: try: cur.execute( f"SELECT {fn}(geom) AS x " f"FROM chart_point_geometry LIMIT 1" ) cur.fetchone() result["srid_fn"] = fn break except Exception: continue return result def classify_invalid_reason(reason: str) -> str: s = (reason or "").lower() if "self-intersection" in s: if "ring self-intersection" in s: return "RING_SELF_INTERSECTION" return "SELF_INTERSECTION" if "too few points" in s: return "TOO_FEW_POINTS" 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 "disconnected interior" in s: return "DISCONNECTED_INTERIOR" if "invalid coordinate" in s or "nan" in s or "inf" in s: return "INVALID_COORDINATE" if "ring" in s and "closed" in s: return "RING_NOT_CLOSED" return "OTHER_INVALID_GEOMETRY" def actual_type_ok(kind: str, geom_type: str) -> bool: if kind == "POINT": return geom_type == "Point" if kind == "LINE": return geom_type in {"LineString", "MultiLineString"} if kind == "AREA": return geom_type in {"Polygon", "MultiPolygon"} if kind == "SOUNDING": return geom_type == "Point" if kind == "COVERAGE": return geom_type in {"Polygon", "MultiPolygon"} return True def intrinsic_check( *, kind: str, feature_id: Optional[int], object_class: str, cell_id: Optional[int], cell_name: str, source_record_index: Optional[int], wkb_data: Any, srid: Any, normalized_srid: Any, feature_bbox: Optional[Tuple[float, float, float, float]], cell_bbox: Optional[Tuple[float, float, float, float]], dataset_bbox: Optional[Tuple[float, float, float, float]], bbox_tolerance: float, boundary_tolerance: float, extra_id: Optional[int] = None, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: """ 返回: stat: 单 geometry 统计 issues: 0..N 条 issue """ base = { "kind": kind, "feature_id": feature_id, "extra_id": extra_id, "object_class": object_class, "cell_id": cell_id, "cell_name": cell_name, "source_record_index": source_record_index, "srid": srid, "normalized_srid": normalized_srid, } stat = dict(base) stat.update({ "parsed": 0, "empty": 0, "valid": None, "geom_type": None, "simple": None, "bounds": None, "touches_cell_boundary": 0, "touches_dataset_bbox": 0, }) issues: List[Dict[str, Any]] = [] if wkb_data is None: x = dict(base) x.update({ "issue_category": "WKB_MISSING", "issue_detail": "DB geometry exists but WKB conversion returned NULL", "invalid_reason": "", "boundary_context": "", }) issues.append(x) return stat, issues if isinstance(wkb_data, memoryview): wkb_data = wkb_data.tobytes() if isinstance(wkb_data, bytearray): wkb_data = bytes(wkb_data) try: geom = load_wkb(wkb_data) stat["parsed"] = 1 except Exception as e: x = dict(base) x.update({ "issue_category": "WKB_PARSE_ERROR", "issue_detail": f"{type(e).__name__}: {e}", "invalid_reason": "", "boundary_context": "", }) issues.append(x) return stat, issues geom_type = getattr(geom, "geom_type", None) stat["geom_type"] = geom_type try: is_empty = bool(geom.is_empty) except Exception: is_empty = False stat["empty"] = int(is_empty) if is_empty: x = dict(base) x.update({ "issue_category": "EMPTY_GEOMETRY", "issue_detail": f"{kind} geometry is EMPTY", "invalid_reason": "", "actual_geom_type": geom_type, "boundary_context": "", }) issues.append(x) return stat, issues try: b = tuple(float(v) for v in geom.bounds) if len(b) == 4 and all(math.isfinite(v) for v in b): stat["bounds"] = b else: b = None except Exception: b = None stat["touches_cell_boundary"] = int(touches_bbox_edge(b, cell_bbox, boundary_tolerance)) stat["touches_dataset_bbox"] = int(touches_bbox_edge(b, dataset_bbox, boundary_tolerance)) boundary_bits = [] if stat["touches_cell_boundary"]: boundary_bits.append("CELL_EDGE") if stat["touches_dataset_bbox"]: boundary_bits.append("DATASET_BBOX_EDGE") boundary_context = "|".join(boundary_bits) if not actual_type_ok(kind, geom_type or ""): x = dict(base) x.update({ "issue_category": "GEOMETRY_TYPE_MISMATCH", "issue_detail": f"expected={kind}, actual={geom_type}", "invalid_reason": "", "actual_geom_type": geom_type, "boundary_context": boundary_context, }) issues.append(x) # 坐标范围:CM93 normalized SRID 当前应为经纬度数据。 if b is None: x = dict(base) x.update({ "issue_category": "NON_FINITE_OR_UNREADABLE_BOUNDS", "issue_detail": "geometry bounds unavailable/non-finite", "invalid_reason": "", "actual_geom_type": geom_type, "boundary_context": boundary_context, }) issues.append(x) elif not bbox_in_lonlat_range(b): x = dict(base) x.update({ "issue_category": "OUT_OF_RANGE_COORDINATE", "issue_detail": f"bounds={b}", "invalid_reason": "", "actual_geom_type": geom_type, "boundary_context": boundary_context, }) issues.append(x) # SRID 一致性。无法探测 SRID 函数时 srid=None,不判 FAIL。 if srid is not None and normalized_srid is not None: try: if int(srid) != int(normalized_srid): x = dict(base) x.update({ "issue_category": "SRID_MISMATCH", "issue_detail": f"geom_srid={srid}, feature_normalized_srid={normalized_srid}", "invalid_reason": "", "actual_geom_type": geom_type, "boundary_context": boundary_context, }) issues.append(x) except Exception: pass # Feature bbox 对账。Sounding 在后面按 feature 聚合单独核对。 if kind in {"POINT", "LINE", "AREA"}: if feature_bbox is None: x = dict(base) x.update({ "issue_category": "FEATURE_BBOX_MISSING", "issue_detail": f"{kind} has geometry but feature bbox is NULL/incomplete", "invalid_reason": "", "actual_geom_type": geom_type, "geometry_bounds": b, "boundary_context": boundary_context, }) issues.append(x) elif b is not None and not bbox_close(b, feature_bbox, bbox_tolerance): x = dict(base) x.update({ "issue_category": "FEATURE_BBOX_MISMATCH", "issue_detail": f"feature_bbox={feature_bbox}, geometry_bounds={b}, tol={bbox_tolerance}", "invalid_reason": "", "actual_geom_type": geom_type, "geometry_bounds": b, "feature_bbox": feature_bbox, "boundary_context": boundary_context, }) issues.append(x) # Shapely validity: # - Area/Coverage 的 invalid 是核心检查; # - Point/Line 也记录 validity; # - Line 非 simple 不等同 invalid,只作为统计,不生成 issue。 try: valid = bool(geom.is_valid) stat["valid"] = int(valid) except Exception: valid = True stat["valid"] = None try: stat["simple"] = int(bool(geom.is_simple)) except Exception: stat["simple"] = None if not valid: try: reason = explain_validity(geom) except Exception as e: reason = f"validity explanation unavailable: {e}" reason_class = classify_invalid_reason(reason) x = dict(base) x.update({ "issue_category": reason_class, "issue_detail": reason, "invalid_reason": reason, "actual_geom_type": geom_type, "geometry_bounds": b, "boundary_context": boundary_context, }) issues.append(x) return stat, issues def get_dataset_bbox(cur, dataset_id: int) -> Optional[Tuple[float, float, float, float]]: cur.execute(""" SELECT MIN(bbox_min_lon) AS minx, MIN(bbox_min_lat) AS miny, MAX(bbox_max_lon) AS maxx, MAX(bbox_max_lat) AS maxy FROM chart_cell WHERE dataset_id=%s AND bbox_min_lon IS NOT NULL AND bbox_min_lat IS NOT NULL AND bbox_max_lon IS NOT NULL AND bbox_max_lat IS NOT NULL """, (dataset_id,)) r = cur.fetchone() or {} vals = [safe_float(r.get("minx")), safe_float(r.get("miny")), safe_float(r.get("maxx")), safe_float(r.get("maxy"))] if any(v is None for v in vals): return None return vals[0], vals[1], vals[2], vals[3] def audit_mapping(cur, dataset_id: int) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: """ 检查 feature.geometry_type 与实际存储表是否一致。 """ mapping_issues: List[Dict[str, Any]] = [] stats: Dict[str, Any] = {} # Feature type counts ft = rows(cur, """ SELECT geometry_type, COUNT(*) AS n FROM chart_feature WHERE dataset_id=%s GROUP BY geometry_type ORDER BY geometry_type """, (dataset_id,)) stats["feature_type_counts"] = ft # 实体表 counts(只统计 Dataset 1) for name, table in [ ("point_rows", "chart_point_geometry"), ("line_rows", "chart_line_geometry"), ("area_rows", "chart_area_geometry"), ]: stats[name] = int(scalar(cur, f""" SELECT COUNT(*) FROM {table} g JOIN chart_feature f ON f.id=g.feature_id WHERE f.dataset_id=%s """, (dataset_id,)) or 0) stats["sounding_rows"] = int(scalar(cur, """ SELECT COUNT(*) FROM chart_sounding_point s JOIN chart_feature f ON f.id=s.feature_id WHERE f.dataset_id=%s """, (dataset_id,)) or 0) # 缺 expected geometry checks = [ ("POINT_MISSING_GEOMETRY", "POINT", "chart_point_geometry"), ("LINE_MISSING_GEOMETRY", "LINE", "chart_line_geometry"), ("AREA_MISSING_GEOMETRY", "AREA", "chart_area_geometry"), ] for issue_cat, gtype, table in checks: rs = rows(cur, f""" SELECT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type FROM chart_feature f JOIN chart_cell c ON c.id=f.cell_id LEFT JOIN {table} g ON g.feature_id=f.id WHERE f.dataset_id=%s AND f.geometry_type=%s AND g.feature_id IS NULL ORDER BY f.id """, (dataset_id, gtype)) for r in rs: r["issue_category"] = issue_cat mapping_issues.append(r) # SOUNDING feature 没有任何 sounding point rs = rows(cur, """ SELECT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type FROM chart_feature f JOIN chart_cell c ON c.id=f.cell_id LEFT JOIN chart_sounding_point s ON s.feature_id=f.id WHERE f.dataset_id=%s AND f.geometry_type='SOUNDING' GROUP BY f.id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type HAVING COUNT(s.id)=0 ORDER BY f.id """, (dataset_id,)) for r in rs: r["issue_category"] = "SOUNDING_MISSING_POINTS" mapping_issues.append(r) # geometry 表串类型 for issue_cat, table, expected in [ ("POINT_TABLE_WRONG_FEATURE_TYPE", "chart_point_geometry", "POINT"), ("LINE_TABLE_WRONG_FEATURE_TYPE", "chart_line_geometry", "LINE"), ("AREA_TABLE_WRONG_FEATURE_TYPE", "chart_area_geometry", "AREA"), ]: rs = rows(cur, f""" SELECT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type FROM {table} 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.geometry_type<>%s ORDER BY f.id """, (dataset_id, expected)) for r in rs: r["issue_category"] = issue_cat mapping_issues.append(r) # sounding point 串到非 SOUNDING feature rs = rows(cur, """ SELECT DISTINCT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type FROM chart_sounding_point s JOIN chart_feature f ON f.id=s.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE f.dataset_id=%s AND f.geometry_type<>'SOUNDING' ORDER BY f.id """, (dataset_id,)) for r in rs: r["issue_category"] = "SOUNDING_POINT_WRONG_FEATURE_TYPE" mapping_issues.append(r) # META/UNKNOWN 不应偷偷挂实际 geometry rs = rows(cur, """ SELECT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type, CASE WHEN pg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_point, CASE WHEN lg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_line, CASE WHEN ag.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_area, COUNT(sp.id) AS sounding_point_count FROM chart_feature f JOIN chart_cell c ON c.id=f.cell_id LEFT JOIN chart_point_geometry pg ON pg.feature_id=f.id LEFT JOIN chart_line_geometry lg ON lg.feature_id=f.id LEFT JOIN chart_area_geometry ag ON ag.feature_id=f.id LEFT JOIN chart_sounding_point sp ON sp.feature_id=f.id WHERE f.dataset_id=%s AND f.geometry_type IN ('META','UNKNOWN') GROUP BY f.id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.raw_geotype, f.geometry_type, pg.feature_id, lg.feature_id, ag.feature_id HAVING has_point=1 OR has_line=1 OR has_area=1 OR sounding_point_count>0 ORDER BY f.id """, (dataset_id,)) for r in rs: r["issue_category"] = "META_OR_UNKNOWN_HAS_UNEXPECTED_GEOMETRY" mapping_issues.append(r) stats["mapping_issue_count"] = len(mapping_issues) stats["mapping_issue_categories"] = dict(Counter(r["issue_category"] for r in mapping_issues)) return mapping_issues, stats def scan_feature_geometry_table( cur, *, table: str, kind: str, dataset_id: int, wkb_fn: str, srid_fn: Optional[str], dataset_bbox: Optional[Tuple[float, float, float, float]], chunk_size: int, bbox_tolerance: float, boundary_tolerance: float, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: last_id = 0 stats = Counter() geom_types = Counter() invalid_reasons = Counter() issues: List[Dict[str, Any]] = [] srid_expr = f"{srid_fn}(g.geom)" if srid_fn else "NULL" while True: sql = f""" SELECT g.feature_id, {wkb_fn}(g.geom) AS geom_wkb, {srid_expr} AS geom_srid, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.normalized_srid, 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 {table} 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 """ 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["feature_id"]) fb = bbox_tuple(r) cb = bbox_tuple(r, "cell_") stat, found = intrinsic_check( kind=kind, feature_id=r["feature_id"], object_class=r.get("object_class") or "", cell_id=r.get("cell_id"), cell_name=r.get("cell_name") or "", source_record_index=r.get("source_record_index"), wkb_data=r.get("geom_wkb"), srid=r.get("geom_srid"), normalized_srid=r.get("normalized_srid"), feature_bbox=fb, cell_bbox=cb, dataset_bbox=dataset_bbox, bbox_tolerance=bbox_tolerance, boundary_tolerance=boundary_tolerance, ) stats["rows"] += 1 stats["parsed"] += stat["parsed"] stats["empty"] += stat["empty"] if stat["valid"] == 1: stats["valid"] += 1 elif stat["valid"] == 0: stats["invalid"] += 1 if kind == "LINE" and stat["simple"] == 0: stats["non_simple_line_info"] += 1 if stat["touches_cell_boundary"]: stats["touch_cell_boundary_info"] += 1 if stat["touches_dataset_bbox"]: stats["touch_dataset_bbox_info"] += 1 geom_types[stat.get("geom_type") or "UNPARSED"] += 1 for x in found: issues.append(x) invalid_reasons[x["issue_category"]] += 1 print( f" {kind:<8} scanned={stats['rows']:,} " f"issues={len(issues):,} last_feature_id={last_id}" ) return { "kind": kind, "table": table, "rows": stats["rows"], "parsed": stats["parsed"], "empty": stats["empty"], "valid": stats["valid"], "invalid": stats["invalid"], "non_simple_line_info": stats["non_simple_line_info"], "touch_cell_boundary_info": stats["touch_cell_boundary_info"], "touch_dataset_bbox_info": stats["touch_dataset_bbox_info"], "actual_geom_types": dict(geom_types), "issue_count": len(issues), "issue_categories": dict(invalid_reasons), }, issues def scan_soundings( cur, *, dataset_id: int, wkb_fn: str, srid_fn: Optional[str], dataset_bbox: Optional[Tuple[float, float, float, float]], chunk_size: int, bbox_tolerance: float, boundary_tolerance: float, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]]]: last_id = 0 stats = Counter() geom_types = Counter() issues: List[Dict[str, Any]] = [] # feature_id -> [minx,miny,maxx,maxy,count] feature_bounds: Dict[int, List[float]] = {} srid_expr = f"{srid_fn}(s.geom)" if srid_fn else "NULL" while True: sql = f""" SELECT s.id AS sounding_id, s.feature_id, s.depth, {wkb_fn}(s.geom) AS geom_wkb, {srid_expr} AS geom_srid, f.object_class, f.cell_id, c.cell_name, f.source_record_index, f.normalized_srid, 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_sounding_point s JOIN chart_feature f ON f.id=s.feature_id JOIN chart_cell c ON c.id=f.cell_id WHERE f.dataset_id=%s AND s.id>%s ORDER BY s.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["sounding_id"]) fb = bbox_tuple(r) cb = bbox_tuple(r, "cell_") stat, found = intrinsic_check( kind="SOUNDING", feature_id=r["feature_id"], extra_id=r["sounding_id"], object_class=r.get("object_class") or "", cell_id=r.get("cell_id"), cell_name=r.get("cell_name") or "", source_record_index=r.get("source_record_index"), wkb_data=r.get("geom_wkb"), srid=r.get("geom_srid"), normalized_srid=r.get("normalized_srid"), feature_bbox=fb, cell_bbox=cb, dataset_bbox=dataset_bbox, bbox_tolerance=bbox_tolerance, boundary_tolerance=boundary_tolerance, ) stats["rows"] += 1 stats["parsed"] += stat["parsed"] stats["empty"] += stat["empty"] if stat["valid"] == 1: stats["valid"] += 1 elif stat["valid"] == 0: stats["invalid"] += 1 geom_types[stat.get("geom_type") or "UNPARSED"] += 1 # depth 必须 finite depth = safe_float(r.get("depth")) if depth is None: issues.append({ "kind": "SOUNDING", "feature_id": r["feature_id"], "extra_id": r["sounding_id"], "object_class": r.get("object_class") or "", "cell_id": r.get("cell_id"), "cell_name": r.get("cell_name") or "", "source_record_index": r.get("source_record_index"), "issue_category": "INVALID_SOUNDING_DEPTH", "issue_detail": f"depth={r.get('depth')}", "boundary_context": "", }) issues.extend(found) b = stat.get("bounds") if b and len(b) == 4: fid = int(r["feature_id"]) if fid not in feature_bounds: feature_bounds[fid] = [b[0], b[1], b[2], b[3], 1] else: a = feature_bounds[fid] a[0] = min(a[0], b[0]) a[1] = min(a[1], b[1]) a[2] = max(a[2], b[2]) a[3] = max(a[3], b[3]) a[4] += 1 print( f" SOUNDING scanned={stats['rows']:,} " f"issues={len(issues):,} last_sounding_id={last_id}" ) # 按 feature 聚合 sounding bbox 与 chart_feature bbox 对账。 bbox_issues: List[Dict[str, Any]] = [] feature_ids = list(feature_bounds.keys()) # 704 个 feature 左右,可直接分批 IN for pos in range(0, len(feature_ids), 500): ids = feature_ids[pos:pos+500] placeholders = ",".join(["%s"] * len(ids)) rs = rows(cur, f""" SELECT f.id AS feature_id, f.object_class, f.cell_id, c.cell_name, f.source_record_index, 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_feature f JOIN chart_cell c ON c.id=f.cell_id WHERE f.id IN ({placeholders}) """, ids) for r in rs: fid = int(r["feature_id"]) a = feature_bounds[fid] actual = (a[0], a[1], a[2], a[3]) fb = bbox_tuple(r) cb = bbox_tuple(r, "cell_") context = [] if touches_bbox_edge(actual, cb, boundary_tolerance): context.append("CELL_EDGE") if touches_bbox_edge(actual, dataset_bbox, boundary_tolerance): context.append("DATASET_BBOX_EDGE") if fb is None: bbox_issues.append({ "feature_id": fid, "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"), "issue_category": "SOUNDING_FEATURE_BBOX_MISSING", "point_count": int(a[4]), "actual_bbox": actual, "feature_bbox": None, "boundary_context": "|".join(context), }) elif not bbox_close(actual, fb, bbox_tolerance): bbox_issues.append({ "feature_id": fid, "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"), "issue_category": "SOUNDING_FEATURE_BBOX_MISMATCH", "point_count": int(a[4]), "actual_bbox": actual, "feature_bbox": fb, "boundary_context": "|".join(context), }) return { "kind": "SOUNDING", "table": "chart_sounding_point", "rows": stats["rows"], "parsed": stats["parsed"], "empty": stats["empty"], "valid": stats["valid"], "invalid": stats["invalid"], "actual_geom_types": dict(geom_types), "issue_count_excluding_feature_bbox": len(issues), "feature_bbox_issue_count": len(bbox_issues), }, issues, bbox_issues def scan_coverage( cur, *, dataset_id: int, wkb_fn: str, srid_fn: Optional[str], dataset_bbox: Optional[Tuple[float, float, float, float]], bbox_tolerance: float, boundary_tolerance: float, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: srid_expr = f"{srid_fn}(v.geom)" if srid_fn else "NULL" cur.execute(f""" SELECT v.id AS coverage_id, v.cell_id, c.cell_name, v.source_record_index, {wkb_fn}(v.geom) AS geom_wkb, {srid_expr} AS geom_srid, 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,)) rs = list(cur.fetchall()) stats = Counter() geom_types = Counter() issues: List[Dict[str, Any]] = [] for r in rs: cb = bbox_tuple(r, "cell_") stat, found = intrinsic_check( kind="COVERAGE", feature_id=None, extra_id=r["coverage_id"], object_class="COVERAGE", cell_id=r.get("cell_id"), cell_name=r.get("cell_name") or "", source_record_index=r.get("source_record_index"), wkb_data=r.get("geom_wkb"), srid=r.get("geom_srid"), normalized_srid=None, feature_bbox=None, cell_bbox=cb, dataset_bbox=dataset_bbox, bbox_tolerance=bbox_tolerance, boundary_tolerance=boundary_tolerance, ) stats["rows"] += 1 stats["parsed"] += stat["parsed"] stats["empty"] += stat["empty"] if stat["valid"] == 1: stats["valid"] += 1 elif stat["valid"] == 0: stats["invalid"] += 1 geom_types[stat.get("geom_type") or "UNPARSED"] += 1 issues.extend(found) return { "kind": "COVERAGE", "table": "chart_coverage", "rows": stats["rows"], "parsed": stats["parsed"], "empty": stats["empty"], "valid": stats["valid"], "invalid": stats["invalid"], "actual_geom_types": dict(geom_types), "issue_count": len(issues), }, issues def zip_directory(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 MySQL 实体空间 geometry 只读审计 v3" ) 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( "--bbox-tolerance", type=float, default=1e-6, help="Feature bbox 与实际 geometry bounds 对账容差(degree),默认 1e-6" ) p.add_argument( "--boundary-tolerance", type=float, default=1e-5, help="仅用于标注 geometry 是否碰 Cell/Dataset bbox 边缘,不用于 PASS/FAIL" ) p.add_argument("--output", default=None) p.add_argument( "--skip-coverage", action="store_true", help="跳过 chart_coverage geometry 审计" ) 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_spatial_audit_v3_dataset_{args.dataset_id}_{stamp}" ) outdir.mkdir(parents=True, exist_ok=True) report: Dict[str, Any] = { "version": "v3.0", "generated_at": datetime.now().isoformat(timespec="seconds"), "dataset_id": args.dataset_id, "database": args.database, "read_only": True, "pbf": "OUT_OF_SCOPE", "dataset_scope_note": ( "当前 CM93JAPAN 只是日本沿海局部子集,并非完整 CM93。" "不把跨未导入海域/邻区的不连续性作为 geometry corruption。" ), "warnings": [], "server": {}, "dataset_bbox": None, "counts": {}, "mapping": {}, "geometry_stats": [], "issue_summary": {}, } 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"]) print("[1/8] 读取服务器与 Dataset 基线...") report["server"]["version"] = scalar(cur, "SELECT VERSION()") try: report["server"]["version_comment"] = scalar(cur, "SELECT @@version_comment") except Exception: report["server"]["version_comment"] = None funcs = probe_spatial_functions(cur) report["server"]["spatial_functions"] = funcs if not funcs["wkb_fn"]: raise RuntimeError( "无法找到可用的 geometry -> WKB 函数 " "(ST_AsBinary/ST_AsWKB/AsBinary/AsWKB)" ) print(f" DB: {report['server']['version']}") print(f" WKB function: {funcs['wkb_fn']}") print(f" SRID function: {funcs['srid_fn']}") dataset_bbox = get_dataset_bbox(cur, args.dataset_id) report["dataset_bbox"] = dataset_bbox print(f" Dataset bbox: {dataset_bbox}") # 基线 counts report["counts"]["cells"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_cell WHERE dataset_id=%s", (args.dataset_id,)) or 0) report["counts"]["features"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s", (args.dataset_id,)) or 0) report["counts"]["attributes"] = int(scalar(cur, """ SELECT COUNT(*) FROM chart_feature_attribute a JOIN chart_feature f ON f.id=a.feature_id WHERE f.dataset_id=%s """, (args.dataset_id,)) or 0) report["counts"]["sounding_points"] = int(scalar(cur, """ SELECT COUNT(*) FROM chart_sounding_point s JOIN chart_feature f ON f.id=s.feature_id WHERE f.dataset_id=%s """, (args.dataset_id,)) or 0) report["counts"]["raw_geotype16_meta"] = int(scalar(cur, """ SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND raw_geotype=16 AND geometry_type='META' """, (args.dataset_id,)) or 0) report["counts"]["raw_geotype16_geometry_issue"] = int(scalar(cur, """ SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND raw_geotype=16 AND geometry_type='META' AND geometry_issue IS NOT NULL """, (args.dataset_id,)) or 0) print("[2/8] 检查 Feature geometry_type ↔ 实际 geometry 表映射...") mapping_issues, mapping_stats = audit_mapping(cur, args.dataset_id) report["mapping"] = mapping_stats write_csv(outdir / "feature_geometry_mapping_issues.csv", mapping_issues) print(f" mapping issues: {len(mapping_issues):,}") all_intrinsic_issues: List[Dict[str, Any]] = [] geometry_stats: List[Dict[str, Any]] = [] print("[3/8] 扫描 Point geometry...") st, iss = scan_feature_geometry_table( cur, table="chart_point_geometry", kind="POINT", dataset_id=args.dataset_id, wkb_fn=funcs["wkb_fn"], srid_fn=funcs["srid_fn"], dataset_bbox=dataset_bbox, chunk_size=args.chunk_size, bbox_tolerance=args.bbox_tolerance, boundary_tolerance=args.boundary_tolerance, ) geometry_stats.append(st) all_intrinsic_issues.extend(iss) print("[4/8] 扫描 Line geometry...") st, iss = scan_feature_geometry_table( cur, table="chart_line_geometry", kind="LINE", dataset_id=args.dataset_id, wkb_fn=funcs["wkb_fn"], srid_fn=funcs["srid_fn"], dataset_bbox=dataset_bbox, chunk_size=args.chunk_size, bbox_tolerance=args.bbox_tolerance, boundary_tolerance=args.boundary_tolerance, ) geometry_stats.append(st) all_intrinsic_issues.extend(iss) print("[5/8] 扫描 Area geometry...") st, iss = scan_feature_geometry_table( cur, table="chart_area_geometry", kind="AREA", dataset_id=args.dataset_id, wkb_fn=funcs["wkb_fn"], srid_fn=funcs["srid_fn"], dataset_bbox=dataset_bbox, chunk_size=args.chunk_size, bbox_tolerance=args.bbox_tolerance, boundary_tolerance=args.boundary_tolerance, ) geometry_stats.append(st) all_intrinsic_issues.extend(iss) print("[6/8] 扫描 Sounding points,并按 Feature 对账 bbox...") st, iss, sounding_bbox_issues = scan_soundings( cur, dataset_id=args.dataset_id, wkb_fn=funcs["wkb_fn"], srid_fn=funcs["srid_fn"], dataset_bbox=dataset_bbox, chunk_size=max(args.chunk_size, 5000), bbox_tolerance=args.bbox_tolerance, boundary_tolerance=args.boundary_tolerance, ) geometry_stats.append(st) all_intrinsic_issues.extend(iss) write_csv(outdir / "sounding_feature_bbox_issues.csv", sounding_bbox_issues) coverage_issues: List[Dict[str, Any]] = [] if not args.skip_coverage: print("[7/8] 扫描 Coverage geometry...") st, coverage_issues = scan_coverage( cur, dataset_id=args.dataset_id, wkb_fn=funcs["wkb_fn"], srid_fn=funcs["srid_fn"], dataset_bbox=dataset_bbox, bbox_tolerance=args.bbox_tolerance, boundary_tolerance=args.boundary_tolerance, ) geometry_stats.append(st) write_csv(outdir / "coverage_geometry_issues.csv", coverage_issues) else: print("[7/8] Coverage skipped by option.") print("[8/8] 汇总输出...") report["geometry_stats"] = geometry_stats # 主 intrinsic issue 输出(Feature Point/Line/Area + sounding point) write_csv(outdir / "intrinsic_geometry_issues.csv", all_intrinsic_issues) write_csv(outdir / "geometry_stats_by_kind.csv", geometry_stats) cat_counts = Counter( x.get("issue_category", "UNKNOWN") for x in all_intrinsic_issues ) cat_rows = [ {"issue_category": k, "count": v} for k, v in cat_counts.most_common() ] write_csv( outdir / "intrinsic_issue_category_counts.csv", cat_rows, ["issue_category", "count"] ) invalid_reason_counts = Counter( x.get("invalid_reason") for x in all_intrinsic_issues if x.get("invalid_reason") ) write_csv( outdir / "invalid_geometry_reason_counts.csv", [ {"reason": k, "count": v} for k, v in invalid_reason_counts.most_common() ], ["reason", "count"] ) obj_counts = Counter( (x.get("object_class") or "", x.get("issue_category") or "") for x in all_intrinsic_issues ) write_csv( outdir / "issue_object_class_counts.csv", [ {"object_class": k[0], "issue_category": k[1], "count": v} for k, v in obj_counts.most_common() ], ["object_class", "issue_category", "count"] ) cell_counts = Counter( (x.get("cell_name") or "", x.get("issue_category") or "") for x in all_intrinsic_issues ) write_csv( outdir / "issue_cell_counts.csv", [ {"cell_name": k[0], "issue_category": k[1], "count": v} for k, v in cell_counts.most_common() ], ["cell_name", "issue_category", "count"] ) # 每类最多 10 条样本 samples = [] sample_seen = Counter() for x in all_intrinsic_issues: cat = x.get("issue_category", "UNKNOWN") if sample_seen[cat] < 10: samples.append(x) sample_seen[cat] += 1 write_csv(outdir / "issue_samples.csv", samples) report["issue_summary"] = { "mapping_issue_count": len(mapping_issues), "intrinsic_issue_count": len(all_intrinsic_issues), "sounding_feature_bbox_issue_count": len(sounding_bbox_issues), "coverage_issue_count": len(coverage_issues), "intrinsic_by_category": dict(cat_counts), "issues_with_cell_boundary_context": sum( 1 for x in all_intrinsic_issues if "CELL_EDGE" in (x.get("boundary_context") or "") ), "issues_with_dataset_bbox_context": sum( 1 for x in all_intrinsic_issues if "DATASET_BBOX_EDGE" in (x.get("boundary_context") or "") ), } # 基线匹配 acceptance = {} for k, exp in EXPECTED_DATASET1.items(): actual = report["counts"].get(k) acceptance[k] = { "actual": actual, "expected": exp, "pass": actual == exp, } acceptance["raw_geotype16_expected_parent_count"] = { "actual": report["counts"]["raw_geotype16_meta"], "expected": 4406, "pass": report["counts"]["raw_geotype16_meta"] == 4406, "interpretation": "EXPECTED_PARENT_NO_GEOMETRY, not intrinsic geometry corruption", } acceptance["mapping"] = { "issue_count": len(mapping_issues), "pass": len(mapping_issues) == 0, } # 注意:intrinsic issue 非 0 时不自动称“Importer FAIL”。 # 需要结合 category、对象类型和 boundary_context 分析。 acceptance["intrinsic_geometry"] = { "issue_count": len(all_intrinsic_issues), "pass_if_zero": len(all_intrinsic_issues) == 0, "note": ( "非零时需要人工分类;局部 Dataset 边界不连续本身不属于 FAIL。" ), } report["acceptance"] = acceptance # JSON (outdir / "spatial_audit_report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8" ) # TXT summary lines = [] lines.append("CM93 -> MySQL 实体空间 Geometry 只读审计 v3") lines.append("=" * 78) lines.append(f"Generated: {report['generated_at']}") lines.append(f"Database: {args.database}") lines.append(f"Dataset ID: {args.dataset_id}") lines.append(f"DB version: {report['server']['version']}") lines.append(f"WKB fn: {funcs['wkb_fn']}") lines.append(f"SRID fn: {funcs['srid_fn']}") lines.append(f"Dataset bbox: {dataset_bbox}") lines.append("") lines.append("DATASET SCOPE") lines.append( " 当前 CM93JAPAN 是日本沿海局部子集,不是完整 CM93。" ) lines.append( " 跨未导入海域/邻区的几何或对象连续性不作为本轮 FAIL 条件。" ) lines.append( " boundary_context 仅用于后续解释,不会自动把记录判成 importer bug。" ) lines.append("") lines.append("BASELINE COUNTS") for k in ["cells", "features", "attributes", "sounding_points", "raw_geotype16_meta", "raw_geotype16_geometry_issue"]: lines.append(f" {k:36s} {report['counts'].get(k)}") lines.append("") lines.append("FEATURE ↔ GEOMETRY TABLE MAPPING") lines.append(f" mapping issues: {len(mapping_issues)}") for k, v in sorted(mapping_stats.get("mapping_issue_categories", {}).items()): lines.append(f" {k:40s} {v}") lines.append("") lines.append("INTRINSIC GEOMETRY STATS") for s in geometry_stats: lines.append( f" {s['kind']:<9} rows={s.get('rows',0):>9} " f"parsed={s.get('parsed',0):>9} " f"empty={s.get('empty',0):>6} " f"valid={s.get('valid',0):>9} " f"invalid={s.get('invalid',0):>6} " f"issues={s.get('issue_count', s.get('issue_count_excluding_feature_bbox',0)):>6}" ) lines.append( f" actual types={s.get('actual_geom_types')}" ) if s["kind"] == "LINE": lines.append( f" non_simple_line_info={s.get('non_simple_line_info',0)} " f"(informational; not automatically invalid)" ) lines.append("") lines.append("INTRINSIC ISSUE CATEGORIES") if cat_counts: for k, v in cat_counts.most_common(): lines.append(f" {k:36s} {v}") else: lines.append(" none") lines.append("") lines.append("SOUNDING FEATURE BBOX") lines.append(f" bbox issues: {len(sounding_bbox_issues)}") lines.append("") lines.append("COVERAGE") lines.append(f" coverage issues: {len(coverage_issues)}") lines.append("") lines.append("BOUNDARY CONTEXT (INFORMATIONAL)") lines.append( f" intrinsic issues touching cell bbox edge: " f"{report['issue_summary']['issues_with_cell_boundary_context']}" ) lines.append( f" intrinsic issues touching dataset bbox edge: " f"{report['issue_summary']['issues_with_dataset_bbox_context']}" ) lines.append("") lines.append("IMPORTANT INTERPRETATION") lines.append( " raw_geotype=16 / META 的 4406 条正常 parent-no-geometry " "不进入本轮 intrinsic geometry issue。" ) lines.append( " Line 在 Cell/Dataset 边缘结束、缺少未导入邻区对象,不属于本轮 geometry corruption。" ) lines.append( " Polygon 自相交、WKB 无法解析、坐标越界、类型串表等属于单 geometry 内在问题," "需要单独分析。" ) lines.append("") lines.append("PBF: OUT OF SCOPE / NOT STARTED") (outdir / "spatial_audit_summary.txt").write_text( "\n".join(lines), encoding="utf-8" ) conn.rollback() zpath = zip_directory(outdir) print("") print("审计完成。") print(f"输出目录: {outdir.resolve()}") print(f"ZIP: {zpath.resolve()}") print("") 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()