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