1571 lines
55 KiB
Python
1571 lines
55 KiB
Python
#!/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()
|