720 lines
33 KiB
Python
720 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
CM93 -> MySQL Dataset ID audit + geometry issue classification v2
|
|
Target schema discovered from Dataset 1 audit:
|
|
chart_dataset
|
|
chart_cell
|
|
chart_feature
|
|
chart_feature_attribute
|
|
chart_sounding_point
|
|
chart_point_geometry
|
|
chart_line_geometry
|
|
chart_area_geometry
|
|
chart_coverage
|
|
chart_import_job
|
|
chart_import_event
|
|
|
|
READ ONLY: SELECT only; transaction is opened READ ONLY.
|
|
No geometry repair, no re-import, no PBF.
|
|
|
|
Dependency:
|
|
pip install pymysql
|
|
|
|
Example:
|
|
python .\\cm93_mysql_audit_v2.py --host 127.0.0.1 --port 3306 --user root --database cm93_chart --dataset-id 1
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import getpass
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import traceback
|
|
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("Missing dependency: pymysql")
|
|
print("Install with: pip install pymysql")
|
|
sys.exit(2)
|
|
|
|
EXPECTED = {
|
|
"cells": 784,
|
|
"features": 322465,
|
|
"attributes": 405153,
|
|
"sounding_points": 275700,
|
|
"geometry_issues": 4406,
|
|
}
|
|
|
|
# Conservative text classification. Exact raw issue text is always preserved.
|
|
CATEGORY_RULES: List[Tuple[str, Sequence[str]]] = [
|
|
("INVALID_COORDINATE", [
|
|
r"\bnan\b", r"\binf(?:inity)?\b", r"invalid coordinate", r"coordinate.*out of range",
|
|
r"latitude.*out of range", r"longitude.*out of range", r"non[- ]?finite",
|
|
]),
|
|
("INSUFFICIENT_POINTS", [
|
|
r"insufficient point", r"too few point", r"not enough point", r"minimum.*point",
|
|
r"less than .* points?", r"requires? .* points?",
|
|
]),
|
|
("RING_NOT_CLOSED", [
|
|
r"ring.*not closed", r"not closed.*ring", r"unclosed ring", r"closed linestring",
|
|
]),
|
|
("SELF_INTERSECTION", [
|
|
r"self[- ]?intersection", r"self intersect", r"ring self", r"bow[- ]?tie",
|
|
]),
|
|
("INVALID_RING", [
|
|
r"invalid ring", r"ring.*invalid", r"illegal ring", r"shell.*invalid", r"hole.*invalid",
|
|
r"interior ring", r"exterior ring",
|
|
]),
|
|
("DEGENERATE_GEOMETRY", [
|
|
r"degenerate", r"zero[- ]?length", r"zero[- ]?area", r"collapsed", r"duplicate point",
|
|
r"all points.*same", r"too few distinct",
|
|
]),
|
|
("MULTIPART_ASSEMBLY", [
|
|
r"multipart", r"multi[- ]?part", r"cannot.*assemble", r"failed.*assemble", r"assembly failed",
|
|
r"cannot.*merge", r"failed.*merge", r"stitch",
|
|
]),
|
|
("TOPOLOGY_MISMATCH", [
|
|
r"topolog", r"missing edge", r"edge.*missing", r"missing node", r"node.*missing",
|
|
r"edge reference", r"node reference", r"primitive reference", r"dangling edge", r"broken chain",
|
|
r"disconnected",
|
|
]),
|
|
("GEOMETRY_TYPE_MISMATCH", [
|
|
r"type mismatch", r"geometry type", r"expected.*(?:point|line|polygon|area)",
|
|
r"unexpected.*(?:point|line|polygon|area)", r"wrong.*geometry",
|
|
]),
|
|
("EMPTY_GEOMETRY", [
|
|
r"empty geometry", r"geometry.*empty", r"no geometry", r"null geometry", r"geometry is null",
|
|
r"no coordinates?", r"empty point", r"empty line", r"empty polygon",
|
|
]),
|
|
("CONVERSION_EXCEPTION", [
|
|
r"wkb", r"wkt", r"parse exception", r"conversion exception", r"geometry exception",
|
|
r"failed to convert", r"cannot convert", r"invalid gis data", r"spatial.*exception",
|
|
]),
|
|
]
|
|
|
|
STAGE_RULES: List[Tuple[str, Sequence[str]]] = [
|
|
("MYSQL_WRITE", [r"mysql", r"database", r"insert", r"spatial", r"wkb", r"wkt"]),
|
|
("VALIDATION", [r"valid", r"self[- ]?intersect", r"ring", r"geometry.*invalid"]),
|
|
("ASSEMBLY", [r"assemble", r"assembly", r"merge", r"stitch", r"multipart", r"chain"]),
|
|
("TOPOLOGY", [r"topolog", r"edge", r"node", r"primitive"]),
|
|
("DECODE", [r"decode", r"parse", r"read", r"binary", r"record"]),
|
|
("SOURCE", [r"source", r"raw", r"cm93"]),
|
|
]
|
|
|
|
|
|
def qi(s: str) -> str:
|
|
return "`" + s.replace("`", "``") + "`"
|
|
|
|
|
|
def text(v: Any) -> str:
|
|
if v is None:
|
|
return ""
|
|
if isinstance(v, (bytes, bytearray)):
|
|
return v.decode("utf-8", errors="replace")
|
|
return str(v)
|
|
|
|
|
|
def classify_category(raw: str) -> str:
|
|
for category, pats in CATEGORY_RULES:
|
|
if any(re.search(p, raw, re.I) for p in pats):
|
|
return category
|
|
return "OTHER"
|
|
|
|
|
|
def classify_stage(raw: str) -> str:
|
|
for stage, pats in STAGE_RULES:
|
|
if any(re.search(p, raw, re.I) for p in pats):
|
|
return stage
|
|
return "UNKNOWN"
|
|
|
|
|
|
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 rows(cur, sql: str, params: Sequence[Any] = ()) -> List[Dict[str, Any]]:
|
|
cur.execute(sql, params)
|
|
return list(cur.fetchall())
|
|
|
|
|
|
def table_exists(cur, db: str, table: str) -> bool:
|
|
return bool(scalar(cur, "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s", (db, table)))
|
|
|
|
|
|
def columns(cur, db: str, table: str) -> Dict[str, Dict[str, Any]]:
|
|
out = {}
|
|
for r in rows(cur, """
|
|
SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
|
|
FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s
|
|
ORDER BY ORDINAL_POSITION
|
|
""", (db, table)):
|
|
out[r["COLUMN_NAME"]] = r
|
|
return out
|
|
|
|
|
|
def write_csv(path: Path, data: Iterable[Dict[str, Any]], fieldnames: Optional[List[str]] = None) -> None:
|
|
data = list(data)
|
|
if fieldnames is None:
|
|
fieldnames = []
|
|
seen = set()
|
|
for r in data:
|
|
for k in r:
|
|
if k not in seen:
|
|
fieldnames.append(k)
|
|
seen.add(k)
|
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
|
w = csv.DictWriter(f, fieldnames=fieldnames or [], extrasaction="ignore")
|
|
w.writeheader()
|
|
for r in data:
|
|
w.writerow({k: text(r.get(k)) for k in (fieldnames or [])})
|
|
|
|
|
|
def safe(cur, sql: str, params: Sequence[Any] = ()) -> Dict[str, Any]:
|
|
try:
|
|
return {"ok": True, "rows": rows(cur, sql, params)}
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
def normalize_issue_value(v: Any) -> str:
|
|
s = text(v).strip()
|
|
if not s:
|
|
return ""
|
|
# If JSON, preserve a stable compact textual form to improve grouping.
|
|
try:
|
|
obj = json.loads(s)
|
|
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
except Exception:
|
|
return s
|
|
|
|
|
|
def issue_is_present_sql(col: str) -> str:
|
|
# Works for text/varchar/json-like columns; CAST keeps this generic.
|
|
return f"{qi(col)} IS NOT NULL AND TRIM(CAST({qi(col)} AS CHAR)) <> '' AND LOWER(TRIM(CAST({qi(col)} AS CHAR))) NOT IN ('0','false','none','null','[]','{{}}')"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host", default=os.getenv("MYSQL_HOST", "127.0.0.1"))
|
|
ap.add_argument("--port", type=int, default=int(os.getenv("MYSQL_PORT", "3306")))
|
|
ap.add_argument("--user", default=os.getenv("MYSQL_USER", "root"))
|
|
ap.add_argument("--password", default=os.getenv("MYSQL_PASSWORD"))
|
|
ap.add_argument("--database", default=os.getenv("MYSQL_DATABASE", "cm93_chart"))
|
|
ap.add_argument("--dataset-id", type=int, default=1)
|
|
ap.add_argument("--output", default=None)
|
|
args = ap.parse_args()
|
|
|
|
if args.password is None:
|
|
args.password = getpass.getpass("MySQL password: ")
|
|
|
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
out = Path(args.output or f"cm93_audit_v2_dataset_{args.dataset_id}_{stamp}")
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
|
|
report: Dict[str, Any] = {
|
|
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
|
"database": args.database,
|
|
"dataset_id": args.dataset_id,
|
|
"read_only": True,
|
|
"expected": EXPECTED,
|
|
"schema": {},
|
|
"counts": {},
|
|
"relationships": {},
|
|
"import_jobs": [],
|
|
"cell_status": [],
|
|
"geometry_issue_reconciliation": {},
|
|
"geometry_storage": {},
|
|
"issue_summary": {},
|
|
"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,
|
|
)
|
|
cur = conn.cursor()
|
|
try:
|
|
cur.execute("SET SESSION TRANSACTION READ ONLY")
|
|
except Exception as e:
|
|
report["warnings"].append(f"SET SESSION TRANSACTION READ ONLY failed: {e}")
|
|
cur.execute("START TRANSACTION READ ONLY")
|
|
|
|
required = [
|
|
"chart_dataset", "chart_cell", "chart_feature", "chart_feature_attribute",
|
|
"chart_sounding_point", "chart_point_geometry", "chart_line_geometry",
|
|
"chart_area_geometry", "chart_coverage", "chart_import_job", "chart_import_event",
|
|
]
|
|
existing = {t: table_exists(cur, args.database, t) for t in required}
|
|
report["schema"]["tables"] = existing
|
|
report["schema"]["columns"] = {t: columns(cur, args.database, t) for t, ok in existing.items() if ok}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Basic counts, scoped correctly through joins when table has no dataset_id.
|
|
# ------------------------------------------------------------------
|
|
print("[1/8] Basic counts")
|
|
report["counts"]["datasets"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_dataset WHERE id=%s", (args.dataset_id,)))
|
|
report["counts"]["cells"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_cell WHERE dataset_id=%s", (args.dataset_id,)))
|
|
report["counts"]["features"] = int(scalar(cur, "SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s", (args.dataset_id,)))
|
|
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,)))
|
|
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,)))
|
|
|
|
# Unknown object/attr dictionary mapping checks.
|
|
report["counts"]["feature_object_class_null"] = int(scalar(cur, """
|
|
SELECT COUNT(*) FROM chart_feature
|
|
WHERE dataset_id=%s AND (object_class IS NULL OR TRIM(object_class)='')
|
|
""", (args.dataset_id,)))
|
|
report["counts"]["attribute_name_null"] = 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 AND (a.attribute_name IS NULL OR TRIM(a.attribute_name)='')
|
|
""", (args.dataset_id,)))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Relational integrity.
|
|
# ------------------------------------------------------------------
|
|
print("[2/8] Relational integrity")
|
|
rel_queries = {
|
|
"feature_to_cell_orphan": """
|
|
SELECT COUNT(*) n FROM chart_feature f
|
|
LEFT JOIN chart_cell c ON c.id=f.cell_id
|
|
WHERE f.dataset_id=%s AND c.id IS NULL
|
|
""",
|
|
"feature_cell_wrong_dataset": """
|
|
SELECT COUNT(*) n FROM chart_feature f
|
|
JOIN chart_cell c ON c.id=f.cell_id
|
|
WHERE f.dataset_id=%s AND c.dataset_id<>f.dataset_id
|
|
""",
|
|
"attribute_to_feature_orphan_global": """
|
|
SELECT COUNT(*) n FROM chart_feature_attribute a
|
|
LEFT JOIN chart_feature f ON f.id=a.feature_id
|
|
WHERE f.id IS NULL
|
|
""",
|
|
"sounding_to_feature_orphan_global": """
|
|
SELECT COUNT(*) n FROM chart_sounding_point s
|
|
LEFT JOIN chart_feature f ON f.id=s.feature_id
|
|
WHERE f.id IS NULL
|
|
""",
|
|
"point_geometry_orphan_global": """
|
|
SELECT COUNT(*) n FROM chart_point_geometry g
|
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.id IS NULL
|
|
""",
|
|
"line_geometry_orphan_global": """
|
|
SELECT COUNT(*) n FROM chart_line_geometry g
|
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.id IS NULL
|
|
""",
|
|
"area_geometry_orphan_global": """
|
|
SELECT COUNT(*) n FROM chart_area_geometry g
|
|
LEFT JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.id IS NULL
|
|
""",
|
|
}
|
|
for k, sql in rel_queries.items():
|
|
params = (args.dataset_id,) if "%s" in sql else ()
|
|
report["relationships"][k] = int(scalar(cur, sql, params))
|
|
|
|
# Duplicate geometry rows by feature_id should not exist.
|
|
for table in ["chart_point_geometry", "chart_line_geometry", "chart_area_geometry"]:
|
|
report["relationships"][f"{table}_duplicate_feature_ids"] = int(scalar(cur, f"""
|
|
SELECT COUNT(*) FROM (
|
|
SELECT g.feature_id FROM {table} g
|
|
JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.dataset_id=%s
|
|
GROUP BY g.feature_id HAVING COUNT(*)>1
|
|
) x
|
|
""", (args.dataset_id,)))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Import jobs and cell status: this is likely where 4406 comes from.
|
|
# ------------------------------------------------------------------
|
|
print("[3/8] Import job / cell status")
|
|
report["import_jobs"] = rows(cur, """
|
|
SELECT id, dataset_id, status, started_at, finished_at,
|
|
cells_discovered, cells_done, cells_failed,
|
|
features_source, features_imported,
|
|
attributes_source, attributes_imported,
|
|
unknown_object_count, unknown_attribute_count,
|
|
geometry_invalid_count, geometry_repaired_count, summary_json
|
|
FROM chart_import_job
|
|
WHERE dataset_id=%s
|
|
ORDER BY id
|
|
""", (args.dataset_id,))
|
|
report["cell_status"] = rows(cur, """
|
|
SELECT import_status, COUNT(*) AS cell_count,
|
|
SUM(feature_count) AS feature_count,
|
|
SUM(attribute_count) AS attribute_count,
|
|
SUM(CASE WHEN error_message IS NOT NULL AND TRIM(error_message)<>'' THEN 1 ELSE 0 END) AS cells_with_error_message
|
|
FROM chart_cell
|
|
WHERE dataset_id=%s
|
|
GROUP BY import_status
|
|
ORDER BY cell_count DESC
|
|
""", (args.dataset_id,))
|
|
write_csv(out / "import_jobs.csv", report["import_jobs"])
|
|
write_csv(out / "cell_status.csv", report["cell_status"])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Geometry issue storage in chart_feature and chart_coverage.
|
|
# ------------------------------------------------------------------
|
|
print("[4/8] Geometry issue reconciliation")
|
|
feature_cols = report["schema"]["columns"].get("chart_feature", {})
|
|
coverage_cols = report["schema"]["columns"].get("chart_coverage", {})
|
|
|
|
if "geometry_issue" not in feature_cols:
|
|
raise RuntimeError("chart_feature.geometry_issue not found")
|
|
|
|
issue_pred = issue_is_present_sql("geometry_issue")
|
|
feature_issue_count = int(scalar(cur, f"SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND {issue_pred}", (args.dataset_id,)))
|
|
coverage_issue_count = 0
|
|
if "geometry_issue" in coverage_cols:
|
|
coverage_issue_count = int(scalar(cur, f"SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND {issue_pred}", (args.dataset_id,)))
|
|
|
|
feature_repaired_count = int(scalar(cur, "SELECT COUNT(*) FROM chart_feature WHERE dataset_id=%s AND COALESCE(geometry_repaired,0)<>0", (args.dataset_id,)))
|
|
coverage_repaired_count = 0
|
|
if "geometry_repaired" in coverage_cols:
|
|
coverage_repaired_count = int(scalar(cur, "SELECT COUNT(*) FROM chart_coverage WHERE dataset_id=%s AND COALESCE(geometry_repaired,0)<>0", (args.dataset_id,)))
|
|
|
|
job_invalid_total = sum(int(r.get("geometry_invalid_count") or 0) for r in report["import_jobs"])
|
|
job_repaired_total = sum(int(r.get("geometry_repaired_count") or 0) for r in report["import_jobs"])
|
|
report["geometry_issue_reconciliation"] = {
|
|
"feature_geometry_issue_rows": feature_issue_count,
|
|
"coverage_geometry_issue_rows": coverage_issue_count,
|
|
"feature_geometry_repaired_rows": feature_repaired_count,
|
|
"coverage_geometry_repaired_rows": coverage_repaired_count,
|
|
"sum_import_job_geometry_invalid_count": job_invalid_total,
|
|
"sum_import_job_geometry_repaired_count": job_repaired_total,
|
|
"expected_geometry_issues": EXPECTED["geometry_issues"],
|
|
}
|
|
|
|
# Distribution of raw feature geometry_issue values.
|
|
issue_value_counts = rows(cur, f"""
|
|
SELECT CAST(geometry_issue AS CHAR) AS geometry_issue, COUNT(*) AS n
|
|
FROM chart_feature
|
|
WHERE dataset_id=%s AND {issue_pred}
|
|
GROUP BY CAST(geometry_issue AS CHAR)
|
|
ORDER BY n DESC, geometry_issue
|
|
""", (args.dataset_id,))
|
|
write_csv(out / "geometry_issue_raw_value_counts.csv", issue_value_counts)
|
|
|
|
# Import events related to geometry, including raw event_code/message patterns.
|
|
geom_events = rows(cur, """
|
|
SELECT e.id, e.job_id, e.cell_id, e.level, e.event_code, e.message, e.details_json, e.created_at,
|
|
c.cell_name, c.source_relative_path
|
|
FROM chart_import_event e
|
|
LEFT JOIN chart_import_job j ON j.id=e.job_id
|
|
LEFT JOIN chart_cell c ON c.id=e.cell_id
|
|
WHERE j.dataset_id=%s
|
|
AND (
|
|
LOWER(COALESCE(e.event_code,'')) LIKE '%%geom%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%geom%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%ring%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%polygon%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%line%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%point%%'
|
|
OR LOWER(COALESCE(e.message,'')) LIKE '%%topolog%%'
|
|
)
|
|
ORDER BY e.id
|
|
""", (args.dataset_id,))
|
|
write_csv(out / "geometry_related_import_events.csv", geom_events)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Pull exact issue feature rows. Preserve raw geometry_issue and enough context.
|
|
# ------------------------------------------------------------------
|
|
print("[5/8] Classifying feature.geometry_issue rows")
|
|
issue_rows = rows(cur, f"""
|
|
SELECT
|
|
f.id AS feature_id,
|
|
f.dataset_id,
|
|
f.cell_id,
|
|
c.cell_name,
|
|
c.source_relative_path,
|
|
f.coverage_id,
|
|
f.source_record_index,
|
|
f.object_class_id,
|
|
f.object_class,
|
|
f.geometry_type,
|
|
f.raw_geotype,
|
|
f.scale_code,
|
|
f.native_scale,
|
|
f.geometry_repaired,
|
|
CAST(f.geometry_issue AS CHAR) AS geometry_issue,
|
|
f.bbox_min_lon, f.bbox_min_lat, f.bbox_max_lon, f.bbox_max_lat,
|
|
CASE WHEN pg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_point_geometry,
|
|
CASE WHEN lg.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_line_geometry,
|
|
CASE WHEN ag.feature_id IS NOT NULL THEN 1 ELSE 0 END AS has_area_geometry,
|
|
(SELECT COUNT(*) FROM chart_sounding_point sp WHERE sp.feature_id=f.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
|
|
WHERE f.dataset_id=%s AND {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))}
|
|
ORDER BY f.id
|
|
""", (args.dataset_id,))
|
|
|
|
for r in issue_rows:
|
|
raw = normalize_issue_value(r.get("geometry_issue"))
|
|
r["issue_normalized"] = raw
|
|
r["issue_category"] = classify_category(raw)
|
|
r["issue_stage"] = classify_stage(raw)
|
|
has_geom = bool(r.get("has_point_geometry") or r.get("has_line_geometry") or r.get("has_area_geometry") or int(r.get("sounding_point_count") or 0) > 0)
|
|
r["has_any_geometry_storage"] = 1 if has_geom else 0
|
|
|
|
# Conservative evidence label. Do not claim importer bug from text alone.
|
|
if r["issue_category"] == "OTHER":
|
|
r["assessment"] = "NEEDS_TEXT_REVIEW"
|
|
elif r["issue_category"] in {"TOPOLOGY_MISMATCH", "MULTIPART_ASSEMBLY", "CONVERSION_EXCEPTION"}:
|
|
r["assessment"] = "IMPORTER_OR_SOURCE_CANDIDATE"
|
|
else:
|
|
r["assessment"] = "GEOMETRY_DATA_OR_ASSEMBLY_ISSUE"
|
|
|
|
write_csv(out / "geometry_issues_classified_v2.csv", issue_rows)
|
|
|
|
# Counts by category/stage/object/cell/raw issue text.
|
|
by_cat = Counter(r["issue_category"] for r in issue_rows)
|
|
by_stage = Counter(r["issue_stage"] for r in issue_rows)
|
|
by_cross = Counter((r["issue_stage"], r["issue_category"]) for r in issue_rows)
|
|
by_obj = Counter((text(r.get("object_class_id")), text(r.get("object_class"))) for r in issue_rows)
|
|
by_cell = Counter((text(r.get("cell_name")), text(r.get("source_relative_path"))) for r in issue_rows)
|
|
by_raw = Counter(r["issue_normalized"] for r in issue_rows)
|
|
|
|
issue_summary = {
|
|
"feature_issue_rows_loaded": len(issue_rows),
|
|
"category_sum": sum(by_cat.values()),
|
|
"other_count": by_cat.get("OTHER", 0),
|
|
"by_category": [{"category": k, "count": v} for k, v in by_cat.most_common()],
|
|
"by_stage": [{"stage": k, "count": v} for k, v in by_stage.most_common()],
|
|
"by_stage_category": [{"stage": k[0], "category": k[1], "count": v} for k, v in by_cross.most_common()],
|
|
"by_object_class_top50": [{"object_class_id": k[0], "object_class": k[1], "count": v} for k, v in by_obj.most_common(50)],
|
|
"by_cell_top50": [{"cell_name": k[0], "source_relative_path": k[1], "count": v} for k, v in by_cell.most_common(50)],
|
|
"raw_issue_top100": [{"geometry_issue": k, "count": v} for k, v in by_raw.most_common(100)],
|
|
}
|
|
report["issue_summary"] = issue_summary
|
|
write_csv(out / "geometry_issue_by_category.csv", issue_summary["by_category"])
|
|
write_csv(out / "geometry_issue_by_stage.csv", issue_summary["by_stage"])
|
|
write_csv(out / "geometry_issue_stage_category.csv", issue_summary["by_stage_category"])
|
|
write_csv(out / "geometry_issue_object_class_top50.csv", issue_summary["by_object_class_top50"])
|
|
write_csv(out / "geometry_issue_cell_top50.csv", issue_summary["by_cell_top50"])
|
|
write_csv(out / "geometry_issue_raw_top100.csv", issue_summary["raw_issue_top100"])
|
|
|
|
# Samples: up to 10 per category, prioritizing different object/cell/raw combinations.
|
|
samples = []
|
|
seen_per_cat: Dict[str, set] = defaultdict(set)
|
|
for r in issue_rows:
|
|
cat = r["issue_category"]
|
|
sig = (r.get("object_class_id"), r.get("cell_id"), r.get("issue_normalized"))
|
|
if len(seen_per_cat[cat]) < 10 and sig not in seen_per_cat[cat]:
|
|
samples.append(r)
|
|
seen_per_cat[cat].add(sig)
|
|
write_csv(out / "geometry_issue_samples_v2.csv", samples)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Geometry storage audit: true spatial columns live in separate tables.
|
|
# ------------------------------------------------------------------
|
|
print("[6/8] Spatial geometry storage audit")
|
|
storage = {}
|
|
for name, table in [("POINT", "chart_point_geometry"), ("LINE", "chart_line_geometry"), ("AREA", "chart_area_geometry")]:
|
|
item: Dict[str, Any] = {}
|
|
item["row_count"] = int(scalar(cur, f"""
|
|
SELECT COUNT(*) FROM {table} g
|
|
JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.dataset_id=%s
|
|
""", (args.dataset_id,)))
|
|
item["feature_count"] = int(scalar(cur, f"""
|
|
SELECT COUNT(DISTINCT g.feature_id) FROM {table} g
|
|
JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.dataset_id=%s
|
|
""", (args.dataset_id,)))
|
|
item["null_geom"] = int(scalar(cur, f"""
|
|
SELECT COUNT(*) FROM {table} g
|
|
JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.dataset_id=%s AND g.geom IS NULL
|
|
""", (args.dataset_id,)))
|
|
|
|
# Spatial functions may fail on a bad row. Capture rather than abort entire audit.
|
|
st = safe(cur, f"""
|
|
SELECT
|
|
ST_GeometryType(g.geom) AS geometry_type,
|
|
COUNT(*) AS n,
|
|
SUM(CASE WHEN ST_IsEmpty(g.geom) THEN 1 ELSE 0 END) AS empty_count,
|
|
SUM(CASE WHEN ST_IsValid(g.geom) THEN 1 ELSE 0 END) AS valid_count,
|
|
SUM(CASE WHEN NOT ST_IsValid(g.geom) THEN 1 ELSE 0 END) AS invalid_count
|
|
FROM {table} g
|
|
JOIN chart_feature f ON f.id=g.feature_id
|
|
WHERE f.dataset_id=%s AND g.geom IS NOT NULL
|
|
GROUP BY ST_GeometryType(g.geom)
|
|
ORDER BY n DESC
|
|
""", (args.dataset_id,))
|
|
item["spatial_function_result"] = st
|
|
storage[name] = item
|
|
|
|
# Sounding points have their own geom.
|
|
sounding_item: Dict[str, Any] = {
|
|
"row_count": report["counts"]["sounding_points"],
|
|
"null_geom": 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 AND s.geom IS NULL
|
|
""", (args.dataset_id,))),
|
|
}
|
|
sounding_item["spatial_function_result"] = safe(cur, """
|
|
SELECT ST_GeometryType(s.geom) AS geometry_type, COUNT(*) AS n,
|
|
SUM(CASE WHEN ST_IsEmpty(s.geom) THEN 1 ELSE 0 END) AS empty_count,
|
|
SUM(CASE WHEN ST_IsValid(s.geom) THEN 1 ELSE 0 END) AS valid_count,
|
|
SUM(CASE WHEN NOT ST_IsValid(s.geom) THEN 1 ELSE 0 END) AS invalid_count
|
|
FROM chart_sounding_point s
|
|
JOIN chart_feature f ON f.id=s.feature_id
|
|
WHERE f.dataset_id=%s AND s.geom IS NOT NULL
|
|
GROUP BY ST_GeometryType(s.geom)
|
|
""", (args.dataset_id,))
|
|
storage["SOUNDING"] = sounding_item
|
|
report["geometry_storage"] = storage
|
|
|
|
# Geometry type distribution from feature semantic field, separate from spatial columns.
|
|
geom_type_dist = rows(cur, """
|
|
SELECT geometry_type, raw_geotype, COUNT(*) AS n,
|
|
SUM(CASE WHEN geometry_repaired<>0 THEN 1 ELSE 0 END) AS repaired_n,
|
|
SUM(CASE WHEN geometry_issue IS NOT NULL AND TRIM(CAST(geometry_issue AS CHAR))<>'' THEN 1 ELSE 0 END) AS issue_n
|
|
FROM chart_feature
|
|
WHERE dataset_id=%s
|
|
GROUP BY geometry_type, raw_geotype
|
|
ORDER BY n DESC
|
|
""", (args.dataset_id,))
|
|
write_csv(out / "feature_geometry_type_distribution.csv", geom_type_dist)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cell-level issue rates.
|
|
# ------------------------------------------------------------------
|
|
print("[7/8] Cell issue rates")
|
|
cell_issue_rates = rows(cur, f"""
|
|
SELECT c.id AS cell_id, c.cell_name, c.source_relative_path, c.feature_count,
|
|
COUNT(f.id) AS feature_rows,
|
|
SUM(CASE WHEN {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))} THEN 1 ELSE 0 END) AS issue_count,
|
|
ROUND(100.0 * SUM(CASE WHEN {issue_pred.replace(qi('geometry_issue'), 'f.'+qi('geometry_issue'))} THEN 1 ELSE 0 END) / NULLIF(COUNT(f.id),0), 6) AS issue_rate_pct
|
|
FROM chart_cell c
|
|
LEFT JOIN chart_feature f ON f.cell_id=c.id AND f.dataset_id=c.dataset_id
|
|
WHERE c.dataset_id=%s
|
|
GROUP BY c.id, c.cell_name, c.source_relative_path, c.feature_count
|
|
ORDER BY issue_count DESC, c.id
|
|
""", (args.dataset_id,))
|
|
write_csv(out / "geometry_issue_cell_rates_all.csv", cell_issue_rates)
|
|
write_csv(out / "geometry_issue_cell_top30_count.csv", cell_issue_rates[:30])
|
|
write_csv(out / "geometry_issue_cell_top30_rate.csv", sorted(cell_issue_rates, key=lambda r: float(r.get("issue_rate_pct") or 0), reverse=True)[:30])
|
|
|
|
# ------------------------------------------------------------------
|
|
# Final report and acceptance checks.
|
|
# ------------------------------------------------------------------
|
|
print("[8/8] Writing report")
|
|
report["acceptance"] = {
|
|
"cells_match": report["counts"]["cells"] == EXPECTED["cells"],
|
|
"features_match": report["counts"]["features"] == EXPECTED["features"],
|
|
"attributes_match": report["counts"]["attributes"] == EXPECTED["attributes"],
|
|
"sounding_points_match": report["counts"]["sounding_points"] == EXPECTED["sounding_points"],
|
|
"feature_issue_rows_match_4406": len(issue_rows) == EXPECTED["geometry_issues"],
|
|
"category_sum_equals_issue_rows": sum(by_cat.values()) == len(issue_rows),
|
|
"no_feature_cell_orphans": report["relationships"]["feature_to_cell_orphan"] == 0,
|
|
"no_attribute_orphans": report["relationships"]["attribute_to_feature_orphan_global"] == 0,
|
|
"no_sounding_orphans": report["relationships"]["sounding_to_feature_orphan_global"] == 0,
|
|
}
|
|
|
|
(out / "audit_report_v2.json").write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
|
|
|
|
lines = []
|
|
lines.append("CM93 -> MySQL Dataset ID=1 Audit v2")
|
|
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("Mode: READ ONLY / NO PBF")
|
|
lines.append("")
|
|
lines.append("COUNTS")
|
|
for k, v in report["counts"].items():
|
|
lines.append(f" {k:30s} {v}")
|
|
lines.append("")
|
|
lines.append("RELATIONSHIPS")
|
|
for k, v in report["relationships"].items():
|
|
lines.append(f" {k:40s} {v}")
|
|
lines.append("")
|
|
lines.append("GEOMETRY ISSUE RECONCILIATION")
|
|
for k, v in report["geometry_issue_reconciliation"].items():
|
|
lines.append(f" {k:42s} {v}")
|
|
lines.append("")
|
|
lines.append("ISSUE CATEGORY")
|
|
for r in issue_summary["by_category"]:
|
|
pct = (100.0 * r["count"] / len(issue_rows)) if issue_rows else 0
|
|
lines.append(f" {r['category']:30s} {r['count']:7d} {pct:8.3f}%")
|
|
lines.append("")
|
|
lines.append("ISSUE STAGE")
|
|
for r in issue_summary["by_stage"]:
|
|
pct = (100.0 * r["count"] / len(issue_rows)) if issue_rows else 0
|
|
lines.append(f" {r['stage']:20s} {r['count']:7d} {pct:8.3f}%")
|
|
lines.append("")
|
|
lines.append("TOP RAW ISSUE VALUES")
|
|
for r in issue_summary["raw_issue_top100"][:30]:
|
|
s = r["geometry_issue"].replace("\n", " ")
|
|
lines.append(f" {r['count']:7d} {s[:180]}")
|
|
lines.append("")
|
|
lines.append("ACCEPTANCE")
|
|
for k, v in report["acceptance"].items():
|
|
lines.append(f" {k:42s} {'PASS' if v else 'CHECK'}")
|
|
lines.append("")
|
|
lines.append("IMPORTANT")
|
|
lines.append(" - category_sum must equal feature_issue_rows_loaded.")
|
|
lines.append(" - OTHER is not considered fully understood; inspect raw top values/samples.")
|
|
lines.append(" - No repair or database mutation was performed.")
|
|
lines.append(" - PBF remains out of scope.")
|
|
(out / "audit_summary_v2.txt").write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
conn.rollback()
|
|
print("\n".join(lines))
|
|
print(f"\nOutput: {out.resolve()}")
|
|
return 0
|
|
|
|
except Exception:
|
|
if conn:
|
|
try:
|
|
conn.rollback()
|
|
except Exception:
|
|
pass
|
|
err = traceback.format_exc()
|
|
print(err)
|
|
try:
|
|
(out / "fatal_error.txt").write_text(err, encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
return 1
|
|
finally:
|
|
if conn:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|