Files
CM95/tools/cm95_nav_name_multiscale_audit_v2.py

717 lines
21 KiB
Python
Raw Permalink 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 助航名称多尺度与显式关系审计 v2
目标
====
确认 CM95 自身是否存在可安全写入 NavSea
`navigation_marks.chart_label_text` 的名称来源。
重点:
1. 同时检查 1:20,000 / 1:50,000 / 1:200,000;
2. 优先检查助航对象和 `_texto` 中的显式关系/索引字段;
3. 再检查同 Cell 最近文字;
4. 不自动把最近文字当名称;
5. 不读取或反灌 NEWPEC/reference PBF 名称;
6. 只读 MySQL,不修改 importer/schema。
默认 AOI:
z10/914/385
lon 141.328125 .. 141.6796875
lat 40.44694705960049 .. 40.71395582628604
输出:
- cm95_nav_name_multiscale_audit_v2.json
- cm95_nav_name_multiscale_groups_v2.csv
- cm95_nav_text_relation_fields_v2.csv
- cm95_nav_name_multiscale_audit_v2.txt
"""
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
from typing import Any
import pymysql
from pymysql.cursors import DictCursor
NAV_CLASSES = {
"LIGHTS",
"BCNLAT",
"BCNSPP",
"BOYSPP",
"RTPBCN",
"TOWERS",
"_extgn",
"SISTAT",
"SISTAW",
}
DEFAULT_SCALES = (20000, 50000, 200000)
DIRECT_NAME_KEYS = ("OBJNAM", "NOBJNM")
RELATION_KEY_HINTS = (
"relation",
"related",
"index",
"record",
"assoc",
"link",
"ref",
"cm93",
)
DEFAULT_BBOX = (
141.328125,
40.44694705960049,
141.6796875,
40.71395582628604,
)
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(
"--scales",
default="20000,50000,200000",
help="逗号分隔,例如 20000,50000,200000",
)
p.add_argument("--min-lon", type=float, default=DEFAULT_BBOX[0])
p.add_argument("--min-lat", type=float, default=DEFAULT_BBOX[1])
p.add_argument("--max-lon", type=float, default=DEFAULT_BBOX[2])
p.add_argument("--max-lat", type=float, default=DEFAULT_BBOX[3])
p.add_argument("--radius-m", type=float, default=800.0)
p.add_argument("--candidate-limit", type=int, default=12)
p.add_argument("--output-dir", default=".")
return p.parse_args()
def json_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return value
if isinstance(value, (bytes, bytearray, memoryview)):
value = bytes(value).decode("utf-8", "replace")
try:
obj = json.loads(value)
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def relation20(attrs: dict[str, Any]):
value = attrs.get("_cm93_relation20_raw")
if value is None:
return None
try:
return int(value)
except Exception:
return str(value)
def texta(attrs: dict[str, Any]):
value = attrs.get("_texta")
if not isinstance(value, dict):
return None, None
text = value.get("text")
prefix = value.get("prefix")
if text is None:
return None, prefix
text = str(text).strip()
return (text or None), prefix
def direct_names(attrs: dict[str, Any]):
out = []
for key in DIRECT_NAME_KEYS:
value = attrs.get(key)
if isinstance(value, str) and value.strip():
out.append({"field": key, "text": value.strip()})
return out
def relation_like_fields(attrs: dict[str, Any]):
out = {}
for key, value in attrs.items():
lk = str(key).lower()
if any(h in lk for h in RELATION_KEY_HINTS):
out[key] = value
return out
def haversine_m(lon1, lat1, lon2, lat2):
r = 6371008.8
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = p2 - p1
dl = math.radians(lon2 - lon1)
a = (
math.sin(dp / 2) ** 2
+ math.cos(p1) * math.cos(p2)
* math.sin(dl / 2) ** 2
)
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
def flatten_relation_values(obj: Any):
"""用于审计,不解释语义。把标量/list/dict转成可比较字符串集合。"""
values = set()
def walk(v):
if v is None:
return
if isinstance(v, dict):
for kk, vv in v.items():
values.add(f"{kk}={json.dumps(vv, ensure_ascii=False, sort_keys=True, default=str)}")
walk(vv)
elif isinstance(v, list):
for item in v:
walk(item)
else:
values.add(str(v))
walk(obj)
return values
def write_csv(path: Path, rows, fields):
with path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for row in rows:
out = {}
for field in fields:
value = row.get(field)
if isinstance(value, (dict, list, tuple)):
value = json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
default=str,
)
out[field] = value
w.writerow(out)
def main():
a = parse_args()
scales = tuple(
sorted(
{
int(x.strip())
for x in a.scales.split(",")
if x.strip()
}
)
)
if not scales:
raise SystemExit("scales 不能为空")
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,
)
nav_rows = []
text_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")
nav_ph = ",".join(["%s"] * len(NAV_CLASSES))
scale_ph = ",".join(["%s"] * len(scales))
cur.execute(
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 AS lon,
f.bbox_min_lat AS 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.native_scale IN ({scale_ph})
AND f.geometry_type='POINT'
AND f.object_class IN ({nav_ph})
AND f.bbox_min_lon BETWEEN %s AND %s
AND f.bbox_min_lat BETWEEN %s AND %s
ORDER BY
f.native_scale,
f.cell_id,
f.source_record_index
""",
(
a.dataset_id,
*scales,
*sorted(NAV_CLASSES),
a.min_lon,
a.max_lon,
a.min_lat,
a.max_lat,
),
)
nav_rows = list(cur.fetchall())
lat_pad = a.radius_m / 111320.0
mid_lat = (a.min_lat + a.max_lat) / 2.0
lon_pad = (
a.radius_m
/ max(
1.0,
111320.0 * math.cos(math.radians(mid_lat)),
)
)
cur.execute(
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 AS lon,
f.bbox_min_lat AS 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.native_scale IN ({scale_ph})
AND f.geometry_type='POINT'
AND f.object_class='_texto'
AND f.bbox_min_lon BETWEEN %s AND %s
AND f.bbox_min_lat BETWEEN %s AND %s
ORDER BY
f.native_scale,
f.cell_id,
f.source_record_index
""",
(
a.dataset_id,
*scales,
a.min_lon - lon_pad,
a.max_lon + lon_pad,
a.min_lat - lat_pad,
a.max_lat + lat_pad,
),
)
text_rows = list(cur.fetchall())
conn.rollback()
finally:
conn.close()
parsed_nav = []
for row in nav_rows:
attrs = json_dict(row.pop("attributes_json"))
parsed_nav.append(
{
**row,
"feature_id": int(row["feature_id"]),
"cell_id": int(row["cell_id"]),
"source_record_index": int(row["source_record_index"]),
"native_scale": int(row["native_scale"]),
"lon": float(row["lon"]),
"lat": float(row["lat"]),
"attributes": attrs,
"relation20": relation20(attrs),
"direct_names": direct_names(attrs),
"relation_fields": relation_like_fields(attrs),
}
)
parsed_text = []
relation_field_counter = Counter()
for row in text_rows:
attrs = json_dict(row.pop("attributes_json"))
text, prefix = texta(attrs)
rel_fields = relation_like_fields(attrs)
for key in rel_fields:
relation_field_counter[
(int(row["native_scale"]), str(key))
] += 1
parsed_text.append(
{
**row,
"feature_id": int(row["feature_id"]),
"cell_id": int(row["cell_id"]),
"source_record_index": int(row["source_record_index"]),
"native_scale": int(row["native_scale"]),
"lon": float(row["lon"]),
"lat": float(row["lat"]),
"text": text,
"prefix": prefix,
"attributes": attrs,
"relation_fields": rel_fields,
"relation_values": sorted(
flatten_relation_values(rel_fields)
),
}
)
# relation20 仅在同 cell + 同 scale 内组成助航主组。
grouped = defaultdict(list)
standalone = []
for row in parsed_nav:
rel = row["relation20"]
if rel is None:
standalone.append([row])
else:
grouped[
(
row["native_scale"],
row["cell_id"],
rel,
)
].append(row)
nav_groups = list(grouped.values()) + standalone
results = []
per_scale = {}
for members in nav_groups:
scale = members[0]["native_scale"]
cell_id = members[0]["cell_id"]
rel = members[0]["relation20"]
lon = sum(m["lon"] for m in members) / len(members)
lat = sum(m["lat"] for m in members) / len(members)
direct = []
nav_relation_values = set()
for m in members:
for n in m["direct_names"]:
direct.append(
{
"object_class": m["object_class"],
"source_record_index": m["source_record_index"],
**n,
}
)
nav_relation_values |= flatten_relation_values(
m["relation_fields"]
)
candidates = []
# 同比例尺;显式关系优先,同 cell 次之,距离只作证据。
for t in parsed_text:
if t["native_scale"] != scale or not t["text"]:
continue
distance = haversine_m(
lon,
lat,
t["lon"],
t["lat"],
)
explicit_overlap = sorted(
nav_relation_values.intersection(
set(t["relation_values"])
)
)
if distance <= a.radius_m or explicit_overlap:
candidates.append(
{
"feature_id": t["feature_id"],
"cell_id": t["cell_id"],
"source_relative_path": t["source_relative_path"],
"source_record_index": t["source_record_index"],
"native_scale": t["native_scale"],
"lon": t["lon"],
"lat": t["lat"],
"text": t["text"],
"prefix": t["prefix"],
"same_cell": t["cell_id"] == cell_id,
"distance_m": round(distance, 2),
"explicit_relation_overlap": explicit_overlap,
"text_relation_fields": t["relation_fields"],
}
)
candidates.sort(
key=lambda c: (
not bool(c["explicit_relation_overlap"]),
not c["same_cell"],
c["distance_m"],
c["text"],
)
)
explicit = [
c
for c in candidates
if c["explicit_relation_overlap"]
]
same = [c for c in candidates if c["same_cell"]]
nearest_same = (
min(same, key=lambda c: c["distance_m"])
if same
else None
)
result = {
"native_scale": scale,
"cell_id": cell_id,
"source_relative_path": members[0]["source_relative_path"],
"relation20": rel,
"lon": lon,
"lat": lat,
"component_classes": [
m["object_class"] for m in members
],
"component_source_record_indices": [
m["source_record_index"] for m in members
],
"direct_names": direct,
"nav_relation_fields": {
str(m["source_record_index"]): m["relation_fields"]
for m in members
if m["relation_fields"]
},
"explicit_text_candidates": explicit[
: a.candidate_limit
],
"nearest_same_cell_text": (
nearest_same["text"] if nearest_same else None
),
"nearest_same_cell_distance_m": (
nearest_same["distance_m"] if nearest_same else None
),
"same_cell_candidate_count": len(same),
"all_candidate_count": len(candidates),
"candidate_summary": candidates[
: a.candidate_limit
],
}
results.append(result)
results.sort(
key=lambda r: (
r["native_scale"],
r["cell_id"],
-1 if r["relation20"] is None else r["relation20"],
r["lon"],
r["lat"],
)
)
for scale in scales:
subset = [r for r in results if r["native_scale"] == scale]
per_scale[str(scale)] = {
"nav_groups": len(subset),
"groups_with_direct_names": sum(
1 for r in subset if r["direct_names"]
),
"groups_with_explicit_text_candidates": sum(
1
for r in subset
if r["explicit_text_candidates"]
),
"groups_with_same_cell_text_within_50m": sum(
1
for r in subset
if r["nearest_same_cell_distance_m"] is not None
and r["nearest_same_cell_distance_m"] <= 50
),
"groups_with_same_cell_text_within_100m": sum(
1
for r in subset
if r["nearest_same_cell_distance_m"] is not None
and r["nearest_same_cell_distance_m"] <= 100
),
"groups_with_same_cell_text_within_250m": sum(
1
for r in subset
if r["nearest_same_cell_distance_m"] is not None
and r["nearest_same_cell_distance_m"] <= 250
),
}
relation_rows = [
{
"native_scale": scale,
"field_name": field,
"occurrence_count": count,
}
for (scale, field), count in sorted(
relation_field_counter.items()
)
]
report = {
"schema": "navsea.cm95.nav-name-multiscale-audit/v2",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"dataset_id": a.dataset_id,
"scales": list(scales),
"aoi_bbox": [
a.min_lon,
a.min_lat,
a.max_lon,
a.max_lat,
],
"search_radius_m": a.radius_m,
"counts": {
"nav_component_rows": len(parsed_nav),
"nav_groups": len(results),
"texto_rows": len(parsed_text),
"texto_rows_with_text": sum(
1 for t in parsed_text if t["text"]
),
},
"per_scale": per_scale,
"text_relation_field_counts": relation_rows,
"groups": results,
"decision": "READY_FOR_SAFE_NAME_RULE_REVIEW",
}
json_path = (
outdir / "cm95_nav_name_multiscale_audit_v2.json"
)
groups_csv = (
outdir / "cm95_nav_name_multiscale_groups_v2.csv"
)
rel_csv = (
outdir / "cm95_nav_text_relation_fields_v2.csv"
)
txt_path = (
outdir / "cm95_nav_name_multiscale_audit_v2.txt"
)
json_path.write_text(
json.dumps(
report,
ensure_ascii=False,
indent=2,
default=str,
),
encoding="utf-8",
)
write_csv(
groups_csv,
results,
[
"native_scale",
"cell_id",
"source_relative_path",
"relation20",
"lon",
"lat",
"component_classes",
"component_source_record_indices",
"direct_names",
"nav_relation_fields",
"explicit_text_candidates",
"nearest_same_cell_text",
"nearest_same_cell_distance_m",
"same_cell_candidate_count",
"all_candidate_count",
"candidate_summary",
],
)
write_csv(
rel_csv,
relation_rows,
[
"native_scale",
"field_name",
"occurrence_count",
],
)
lines = [
"CM95 / CM93 助航名称多尺度与显式关系审计 v2",
"=" * 82,
f"Dataset ID : {a.dataset_id}",
f"Scales : {','.join(map(str, scales))}",
f"Nav component rows : {len(parsed_nav)}",
f"Nav groups : {len(results)}",
f"_texto rows : {len(parsed_text)}",
"",
]
for scale in scales:
s = per_scale[str(scale)]
lines += [
f"1:{scale}",
f" groups : {s['nav_groups']}",
f" direct names : {s['groups_with_direct_names']}",
f" explicit text relation: {s['groups_with_explicit_text_candidates']}",
f" same-cell <= 50m : {s['groups_with_same_cell_text_within_50m']}",
f" same-cell <= 100m : {s['groups_with_same_cell_text_within_100m']}",
f" same-cell <= 250m : {s['groups_with_same_cell_text_within_250m']}",
"",
]
lines += [
"注意:",
"- 显式关系候选优先于距离候选。",
"- 最近文字仍不会被脚本自动写入 chart_label_text。",
"- 若 20k/50k/200k 都无安全来源,则该数据源的助航名称保持为空,不从 NEWPEC 反灌。",
]
txt_path.write_text("\n".join(lines), encoding="utf-8")
print("\n".join(lines))
print("")
print(json_path)
print(groups_csv)
print(rel_csv)
print(txt_path)
if __name__ == "__main__":
main()