#!/usr/bin/env python3 # -*- coding: utf-8 -*- r""" CM95 / CM93 助航 relation20 全 AOI 审计 v1 用途 ==== 验证选定 PBF AOI(默认 z10/914/385)中 CM93 助航对象的 `_cm93_relation20_raw` 是否可以作为 CM95→NavSea `navigation_marks` 的稳定合成键。 重点检查: - LIGHTS 与 BCNLAT / BCNSPP / BOYSPP / RTPBCN / TOWERS / _extgn 等是否共享 relation20; - 同一 relation20 组的坐标是否一致; - 是否存在一个 relation20 对应多个不相干坐标; - LIGHTS 中有多少可以安全合并,有多少必须独立输出; - relation20 缺失情况; - 跨 Cell 不合并。 本脚本只读 MySQL,不修改 importer/schema。 默认 AOI: z10/914/385 lon 141.328125 .. 141.6796875 lat 40.44694705960049 .. 40.71395582628604 """ from __future__ import annotations import argparse import csv import getpass import json import math from collections import Counter, defaultdict from datetime import datetime from pathlib import Path import pymysql from pymysql.cursors import DictCursor NAV_CLASSES = { "LIGHTS", "BCNLAT", "BCNSPP", "BOYSPP", "RTPBCN", "TOWERS", "_extgn", "SISTAT", "SISTAW", } BASE_CLASSES = { "BCNLAT", "BCNSPP", "BOYSPP", "RTPBCN", "TOWERS", "_extgn", "SISTAT", "SISTAW", } def parse_args(): p = argparse.ArgumentParser() p.add_argument("--host", default="127.0.0.1") p.add_argument("--port", type=int, default=3306) p.add_argument("--user", default="root") p.add_argument("--password", default=None) p.add_argument("--database", default="cm93_chart") p.add_argument("--dataset-id", type=int, default=1) p.add_argument("--min-lon", type=float, default=141.328125) p.add_argument("--min-lat", type=float, default=40.44694705960049) p.add_argument("--max-lon", type=float, default=141.6796875) p.add_argument("--max-lat", type=float, default=40.71395582628604) p.add_argument( "--coord-tolerance-deg", type=float, default=1e-7, help="同 relation20 组坐标最大容差,默认约厘米级~十米级以下的严格检查", ) p.add_argument("--output-dir", default=".") return p.parse_args() def rel20(attrs): if not isinstance(attrs, dict): return None v = attrs.get("_cm93_relation20_raw") if v is None: return None try: return int(v) except Exception: return str(v) def coord_distance_deg(a, b): return math.hypot(a[0] - b[0], a[1] - b[1]) def write_csv(path, rows): rows = list(rows) if not rows: path.write_text("", encoding="utf-8-sig") return keys = [] seen = set() for row in rows: for k in row: if k not in seen: seen.add(k) keys.append(k) with path.open("w", encoding="utf-8-sig", newline="") as f: w = csv.DictWriter(f, fieldnames=keys) w.writeheader() for row in rows: out = {} for k in keys: v = row.get(k) if isinstance(v, (list, dict, tuple)): v = json.dumps(v, ensure_ascii=False, default=str) out[k] = v w.writerow(out) def main(): a = parse_args() if a.password is None: a.password = getpass.getpass("MySQL password: ") outdir = Path(a.output_dir).resolve() outdir.mkdir(parents=True, exist_ok=True) conn = pymysql.connect( host=a.host, port=a.port, user=a.user, password=a.password, database=a.database, charset="utf8mb4", cursorclass=DictCursor, autocommit=False, connect_timeout=20, read_timeout=900, ) rows = [] try: with conn.cursor() as cur: try: cur.execute("SET SESSION TRANSACTION READ ONLY") except Exception: pass try: cur.execute("START TRANSACTION READ ONLY") except Exception: cur.execute("START TRANSACTION") placeholders = ",".join(["%s"] * len(NAV_CLASSES)) sql = f""" SELECT f.id AS feature_id, f.cell_id, c.source_relative_path, f.source_record_index, f.object_class, f.native_scale, f.bbox_min_lon, f.bbox_min_lat, f.attributes_json FROM chart_feature f JOIN chart_cell c ON c.id=f.cell_id WHERE f.dataset_id=%s AND f.geometry_type='POINT' AND f.object_class IN ({placeholders}) AND f.bbox_min_lon BETWEEN %s AND %s AND f.bbox_min_lat BETWEEN %s AND %s ORDER BY f.cell_id, f.native_scale, f.object_class, f.source_record_index """ params = [ a.dataset_id, *sorted(NAV_CLASSES), a.min_lon, a.max_lon, a.min_lat, a.max_lat, ] cur.execute(sql, params) rows = list(cur.fetchall()) conn.rollback() finally: conn.close() for row in rows: attrs = row.get("attributes_json") if isinstance(attrs, str): try: attrs = json.loads(attrs) except Exception: attrs = {} elif attrs is None: attrs = {} row["attributes"] = attrs row["relation20"] = rel20(attrs) row["lon"] = float(row["bbox_min_lon"]) row["lat"] = float(row["bbox_min_lat"]) row.pop("attributes_json", None) groups = defaultdict(list) no_relation = [] for row in rows: rel = row["relation20"] if rel is None: no_relation.append(row) else: groups[(int(row["cell_id"]), rel)].append(row) group_rows = [] light_total = 0 light_with_relation = 0 light_mergeable = 0 light_relation_without_base = 0 coord_conflict_groups = 0 for row in rows: if row["object_class"] == "LIGHTS": light_total += 1 if row["relation20"] is not None: light_with_relation += 1 for (cell_id, relation20), members in sorted(groups.items()): classes = Counter(m["object_class"] for m in members) coords = [(m["lon"], m["lat"]) for m in members] base = coords[0] max_spread = max(coord_distance_deg(base, c) for c in coords) has_light = classes["LIGHTS"] > 0 base_members = [ m for m in members if m["object_class"] in BASE_CLASSES ] has_base = bool(base_members) if max_spread > a.coord_tolerance_deg: coord_conflict_groups += 1 if has_light and has_base and max_spread <= a.coord_tolerance_deg: light_mergeable += classes["LIGHTS"] elif has_light and not has_base: light_relation_without_base += classes["LIGHTS"] group_rows.append({ "cell_id": cell_id, "relation20": relation20, "member_count": len(members), "classes": dict(classes), "has_light": has_light, "has_base_component": has_base, "base_classes": sorted( set(m["object_class"] for m in base_members) ), "max_coord_spread_deg": max_spread, "coord_ok": max_spread <= a.coord_tolerance_deg, "members": [ { "feature_id": m["feature_id"], "source_record_index": m["source_record_index"], "object_class": m["object_class"], "native_scale": m["native_scale"], "lon": m["lon"], "lat": m["lat"], "attributes": m["attributes"], } for m in members ], }) no_relation_counts = Counter( r["object_class"] for r in no_relation ) standalone_lights = [ r for r in rows if r["object_class"] == "LIGHTS" and ( r["relation20"] is None or not any( g["cell_id"] == r["cell_id"] and g["relation20"] == r["relation20"] and g["has_base_component"] and g["coord_ok"] for g in group_rows ) ) ] report = { "version": "v1", "generated_at": datetime.now().isoformat(timespec="seconds"), "dataset_id": a.dataset_id, "aoi_bbox": [ a.min_lon, a.min_lat, a.max_lon, a.max_lat ], "coord_tolerance_deg": a.coord_tolerance_deg, "counts": { "nav_point_rows": len(rows), "relation_groups": len(groups), "rows_without_relation20": len(no_relation), "coord_conflict_groups": coord_conflict_groups, "light_total": light_total, "light_with_relation20": light_with_relation, "light_mergeable_by_relation20": light_mergeable, "light_relation_without_base": light_relation_without_base, "light_standalone_required": len(standalone_lights), }, "no_relation_by_class": dict(no_relation_counts), "groups": group_rows, "standalone_lights": standalone_lights, "decision": ( "PASS_RELATION20_PRIMARY_KEY" if light_total > 0 and light_mergeable > 0 and coord_conflict_groups == 0 else "REVIEW_RELATION20" ), } json_path = outdir / "cm95_nav_aid_relation_audit_v1.json" csv_path = outdir / "cm95_nav_aid_relation_groups_v1.csv" txt_path = outdir / "cm95_nav_aid_relation_audit_v1.txt" json_path.write_text( json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8", ) write_csv(csv_path, group_rows) lines = [ "CM95 / CM93 助航 relation20 全 AOI 审计 v1", "=" * 76, f"NAV point rows : {len(rows)}", f"Relation groups : {len(groups)}", f"Rows without relation20 : {len(no_relation)}", f"Coordinate conflict groups : {coord_conflict_groups}", "", f"LIGHTS total : {light_total}", f"LIGHTS with relation20 : {light_with_relation}", f"LIGHTS mergeable : {light_mergeable}", f"LIGHTS relation no base : {light_relation_without_base}", f"LIGHTS standalone required : {len(standalone_lights)}", "", f"DECISION : {report['decision']}", ] txt_path.write_text("\n".join(lines), encoding="utf-8") print("\n".join(lines)) print("") print("输出:") print(json_path) print(csv_path) print(txt_path) if __name__ == "__main__": main()