Files
CM95/tools/cm95_nav_aid_relation_audit_v1.py

376 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
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()