Files
CM95/tools/cm95_s52_full_database_audit.py
2026-09-17 16:14:47 +08:00

268 lines
11 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.
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import json
import os
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
import pymysql
from cm95pbf.s52 import (
lookup_condition_matches,
normalize_cm93_attributes_for_s52,
normalize_cm93_object_for_s52,
)
IMPLEMENTED_CSP = frozenset({
"DEPARE01", "DEPARE02", "DEPCNT02", "LIGHTS05", "SOUNDG02",
"OBSTRN04", "WRECKS02", "QUAPOS01", "SLCONS03", "RESTRN01",
"RESARE02", "TOPMAR01",
})
def _json_object(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return value
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _geometry_key(geometry_type: str, point_table: str, boundary_table: str):
return {
"POINT": (point_table, "Point"),
"SOUNDING": (point_table, "Point"),
"LINE": ("Lines", "Line"),
"AREA": (boundary_table, "Area"),
}.get(str(geometry_type).upper())
def _prepare_lookups(rows: list[dict[str, Any]]):
index: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
for row in rows:
key = (
str(row.get("object_class") or ""),
str(row.get("table_name") or ""),
str(row.get("geometry_type") or ""),
)
index[key].append(row)
for key, candidates in index.items():
candidates.sort(key=lambda r: (-len(r.get("attribute_conditions_raw") or []), int(r.get("id") or 0)))
return index
def _resolve(candidates: list[dict[str, Any]], attrs: dict[str, Any]):
normalized = normalize_cm93_attributes_for_s52(attrs)
fallback = None
for row in candidates:
conditions = row.get("attribute_conditions_raw") or []
if not conditions:
if fallback is None:
fallback = row
continue
if all(lookup_condition_matches(c, normalized) for c in conditions):
return row
return fallback
def main() -> int:
parser = argparse.ArgumentParser(description="流式审计整个 CM95 Dataset 的 S-52 Lookup/CSP 覆盖率。")
parser.add_argument("--lookups", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--env-file", default=Path(".env"), type=Path)
parser.add_argument("--dataset-id", default=1, type=int)
parser.add_argument("--point-table", default="Paper")
parser.add_argument("--boundary-table", default="Plain")
args = parser.parse_args()
lookup_rows = json.loads(args.lookups.read_text(encoding="utf-8"))
lookup_index = _prepare_lookups(lookup_rows)
load_dotenv(args.env_file)
conn = pymysql.connect(
host=os.environ["CM93_DB_HOST"],
port=int(os.environ.get("CM93_DB_PORT", "3306")),
user=os.environ["CM93_DB_USER"],
password=os.environ["CM93_DB_PASSWORD"],
database=os.environ["CM93_DB_NAME"],
charset=os.environ.get("CM93_DB_CHARSET", "utf8mb4"),
autocommit=True,
cursorclass=pymysql.cursors.SSCursor,
)
total = 0
matched = 0
no_lookup = 0
empty_candidates = 0
by_geometry = Counter()
matched_by_geometry = Counter()
no_lookup_by_key = Counter()
no_candidate_by_key = Counter()
condition_miss_by_key = Counter()
matched_by_key = Counter()
rcids = Counter()
csp_counts = Counter()
implemented_csp_counts = Counter()
unsupported_csp_counts = Counter()
opcode_counts = Counter()
direct_symbol_counts = Counter()
pattern_counts = Counter()
complex_line_counts = Counter()
area_color_counts = Counter()
simple_line_counts = Counter()
tx_rule_counts = Counter()
te_rule_counts = Counter()
substitution_counts = Counter()
sql = (
"SELECT object_class, geometry_type, attributes_json "
"FROM chart_feature WHERE dataset_id=%s ORDER BY id"
)
with conn.cursor() as cur:
cur.execute(sql, (args.dataset_id,))
for object_class, geometry_type, attrs_raw in cur:
total += 1
object_class = str(object_class)
geometry_type = str(geometry_type)
attrs = _json_object(attrs_raw)
lookup_class, lookup_attrs = normalize_cm93_object_for_s52(
object_class, geometry_type, attrs
)
if lookup_class != object_class:
substitution_counts[(object_class, lookup_class, geometry_type)] += 1
by_geometry[geometry_type] += 1
tg = _geometry_key(geometry_type, args.point_table, args.boundary_table)
if tg is None:
no_lookup += 1
empty_candidates += 1
no_lookup_by_key[(object_class, geometry_type)] += 1
no_candidate_by_key[(object_class, geometry_type)] += 1
continue
table_name, lookup_geometry = tg
candidates = lookup_index.get((lookup_class, table_name, lookup_geometry), [])
if not candidates:
no_lookup += 1
empty_candidates += 1
no_lookup_by_key[(object_class, geometry_type)] += 1
no_candidate_by_key[(object_class, geometry_type)] += 1
continue
hit = _resolve(candidates, lookup_attrs)
if hit is None:
no_lookup += 1
no_lookup_by_key[(object_class, geometry_type)] += 1
condition_miss_by_key[(object_class, geometry_type)] += 1
continue
matched += 1
matched_by_geometry[geometry_type] += 1
matched_by_key[(object_class, geometry_type)] += 1
rcids[str(hit.get("rcid") or "")] += 1
rules = hit.get("rules") or []
for rule in rules:
opcode = str(rule.get("opcode") or "")
if opcode:
opcode_counts[opcode] += 1
args_list = rule.get("args") or []
if opcode == "SY" and args_list:
direct_symbol_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "AP" and args_list:
pattern_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "LC" and args_list:
complex_line_counts[str(args_list[0]).strip("'\"")[:8]] += 1
elif opcode == "AC" and args_list:
area_color_counts[str(args_list[0]).strip("'\"")] += 1
elif opcode == "LS" and len(args_list) >= 3:
simple_line_counts[tuple(str(x).strip("'\"") for x in args_list[:3])] += 1
elif opcode == "TX":
tx_rule_counts[tuple(str(x) for x in args_list)] += 1
elif opcode == "TE":
te_rule_counts[tuple(str(x) for x in args_list)] += 1
if opcode == "CS" and args_list:
name = str(args_list[0])[:8]
csp_counts[name] += 1
if name in IMPLEMENTED_CSP:
implemented_csp_counts[name] += 1
else:
unsupported_csp_counts[name] += 1
conn.close()
top_no_lookup = [
{"object_class": k[0], "geometry_type": k[1], "count": v}
for k, v in no_lookup_by_key.most_common()
]
top_matched = [
{"object_class": k[0], "geometry_type": k[1], "count": v}
for k, v in matched_by_key.most_common()
]
report = {
"schema": "navsea.cm95.s52-full-database-audit/v1",
"dataset_id": args.dataset_id,
"point_table": args.point_table,
"boundary_table": args.boundary_table,
"feature_count": total,
"matched_lookup_count": matched,
"no_lookup_count": no_lookup,
"no_candidate_count": empty_candidates,
"condition_no_match_count": no_lookup - empty_candidates,
"match_rate": matched / total if total else None,
"geometry_counts": dict(by_geometry),
"matched_geometry_counts": dict(matched_by_geometry),
"lookup_rcid_top100": rcids.most_common(100),
"opcode_counts": dict(opcode_counts),
"csp_counts": dict(csp_counts),
"implemented_csp_counts": dict(implemented_csp_counts),
"unsupported_csp_counts": dict(unsupported_csp_counts),
"direct_symbol_top100": direct_symbol_counts.most_common(100),
"symbol_resource_counts": direct_symbol_counts.most_common(),
"pattern_resource_counts": pattern_counts.most_common(),
"complex_line_resource_counts": complex_line_counts.most_common(),
"area_color_counts": area_color_counts.most_common(),
"simple_line_rule_counts": [[list(k), v] for k, v in simple_line_counts.most_common()],
"tx_rule_counts": [[list(k), v] for k, v in tx_rule_counts.most_common()],
"te_rule_counts": [[list(k), v] for k, v in te_rule_counts.most_common()],
"class_substitution_counts": [
{"source_object_class": k[0], "s52_object_class": k[1], "geometry_type": k[2], "count": v}
for k, v in substitution_counts.most_common()
],
"no_lookup_by_object_geometry": top_no_lookup,
"no_candidate_by_object_geometry": [
{"object_class": k[0], "geometry_type": k[1], "count": v}
for k, v in no_candidate_by_key.most_common()
],
"condition_no_match_by_object_geometry": [
{"object_class": k[0], "geometry_type": k[1], "count": v}
for k, v in condition_miss_by_key.most_common()
],
"matched_by_object_geometry": top_matched,
"notes": {
"context_dependent_csp": "本报告只统计 Lookup 实际触发量,不执行需要邻接几何、sounding depth、TOPMAR 平台关系等上下文的 CSP。",
"implemented_csp": sorted(IMPLEMENTED_CSP),
},
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({
"feature_count": total,
"matched_lookup_count": matched,
"no_lookup_count": no_lookup,
"no_candidate_count": empty_candidates,
"condition_no_match_count": no_lookup - empty_candidates,
"match_rate": report["match_rate"],
"csp_counts": report["csp_counts"],
"unsupported_csp_counts": report["unsupported_csp_counts"],
"class_substitution_counts": report["class_substitution_counts"],
"top_no_lookup": top_no_lookup[:30],
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())