Files
CM95/tools/cm95_global_mapping_gap_audit_v1.py

612 lines
21 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 全 Dataset → NavSea delivery Mapping Gap 审计 v1
目的
====
在已经完成 production 首轮生成后,对 Dataset 全量 chart_feature 与当前
cm95_to_navsea_v1.yaml 做只读对照,找出“当前 delivery 不会输出”的真实
object_class + geometry_type 组合。
这一步专门用于 production mapping 收口,不修改数据库、不生成 PBF。
默认输入:
dataset_id = 1
mapping = config/mappings/cm95_to_navsea_v1.yaml
默认输出:
build/production/cm95_global_mapping_gap_audit_v1.json
build/production/cm95_global_mapping_gap_variants_v1.csv
build/production/cm95_global_mapping_gap_samples_v1.jsonl
build/production/cm95_global_mapping_gap_summary_v1.txt
审计内容:
- 全 Dataset object_class + geometry_type + native_scale 数量;
- 当前 YAML 是否存在 exact rule;
- 同 object_class 的其它 geometry rule;
- 已知 hard_gate 是否出现未覆盖 geometry variant;
- 显式 ENGINEERING_ONLY / cm95_unmapped;
- 每个 gap variant 的属性键频率、样本值、relation20 覆盖;
- 每个 gap variant 的代表 Feature 样本。
数据库连接直接读取项目 .env:
CM93_DB_HOST
CM93_DB_PORT
CM93_DB_NAME
CM93_DB_USER
CM93_DB_PASSWORD
执行:
& ".\.venv\Scripts\python.exe" .\tools\cm95_global_mapping_gap_audit_v1.py `
--dataset-id 1 `
--mapping .\config\mappings\cm95_to_navsea_v1.yaml
"""
from __future__ import annotations
import argparse
import csv
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
import pymysql
import yaml
from cm93db.config import load_config
DELIVERY_MAIN_STATUSES = {
"MAPPED",
"MAPPED_CONDITIONAL",
"COMPOSITE",
"MAPPED_DERIVED_POINTS",
}
DELIVERY_DERIVED_STATUSES = {
"MAPPED",
"MAPPED_CONDITIONAL",
"DERIVED_LABEL_ONLY",
}
def parse_args():
p = argparse.ArgumentParser(
description="CM95 全 Dataset → NavSea delivery Mapping Gap 审计 v1"
)
p.add_argument("--dataset-id", type=int, default=1)
p.add_argument(
"--mapping",
default=r"config\mappings\cm95_to_navsea_v1.yaml",
)
p.add_argument(
"--output",
default=r"build\production\cm95_global_mapping_gap_audit_v1.json",
)
p.add_argument("--sample-per-variant", type=int, default=6)
p.add_argument("--attribute-value-limit", type=int, default=12)
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", errors="replace")
try:
obj = json.loads(str(value))
return obj if isinstance(obj, dict) else {}
except Exception:
return {}
def scalar_text(value: Any) -> str:
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return str(value)
def load_rules(path: Path):
obj = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
rules = obj.get("rules") or obj.get("mappings") or []
if not isinstance(rules, list):
raise RuntimeError("mapping YAML 中找不到 rules/mappings list")
exact: dict[tuple[str, str], dict[str, Any]] = {}
by_class: dict[str, list[dict[str, Any]]] = defaultdict(list)
for rule in rules:
if not isinstance(rule, dict):
continue
cls = str(rule.get("cm95_object_class") or "").strip()
geom = str(rule.get("geometry_type") or "").strip().upper()
if not cls or not geom:
continue
exact[(cls, geom)] = rule
by_class[cls].append(rule)
return obj, exact, by_class
def has_delivery_output(rule: dict[str, Any]) -> bool:
status = str(rule.get("mapping_status") or "")
target = str(rule.get("target_source_layer") or "")
if target and target != "cm95_unmapped" and status in DELIVERY_MAIN_STATUSES:
return True
derived = rule.get("derived_outputs") or []
if derived and status in DELIVERY_DERIVED_STATUSES:
for item in derived:
if not isinstance(item, dict):
continue
layer = str(item.get("source_layer") or "")
if layer and layer != "cm95_unmapped":
return True
return False
def gap_reason(
cls: str,
geom: str,
exact_rules: dict[tuple[str, str], dict[str, Any]],
by_class: dict[str, list[dict[str, Any]]],
):
rule = exact_rules.get((cls, geom))
class_rules = by_class.get(cls, [])
if rule is None:
inherited_hard_gate = any(
str(r.get("safety_class") or "") == "hard_gate"
for r in class_rules
)
return {
"is_gap": True,
"reason": (
"NO_EXACT_RULE_KNOWN_HARD_GATE_VARIANT"
if inherited_hard_gate
else "NO_EXACT_RULE"
),
"rule": None,
"inherited_hard_gate": inherited_hard_gate,
}
if has_delivery_output(rule):
return {
"is_gap": False,
"reason": "DELIVERED",
"rule": rule,
"inherited_hard_gate": False,
}
target = str(rule.get("target_source_layer") or "")
status = str(rule.get("mapping_status") or "")
if target == "cm95_unmapped":
reason = "EXPLICIT_ENGINEERING_ONLY"
else:
reason = f"NOT_DELIVERED_STATUS_{status or 'EMPTY'}"
return {
"is_gap": True,
"reason": reason,
"rule": rule,
"inherited_hard_gate": (
str(rule.get("safety_class") or "") == "hard_gate"
),
}
def connect():
cfg = load_config()
return pymysql.connect(
host=cfg.host,
port=cfg.port,
user=cfg.user,
password=cfg.password,
database=cfg.database,
charset=cfg.charset,
cursorclass=pymysql.cursors.DictCursor,
autocommit=False,
connect_timeout=20,
read_timeout=900,
write_timeout=30,
)
def main():
a = parse_args()
mapping_path = Path(a.mapping).resolve()
output_path = Path(a.output).resolve()
outdir = output_path.parent
outdir.mkdir(parents=True, exist_ok=True)
_mapping_obj, exact_rules, by_class = load_rules(mapping_path)
with connect() as conn:
try:
conn.cursor().execute("SET SESSION TRANSACTION READ ONLY")
except Exception:
pass
with conn.cursor() as cur:
cur.execute(
"""
SELECT
f.object_class_id,
f.object_class,
f.geometry_type,
f.native_scale,
COUNT(*) AS feature_count,
COUNT(DISTINCT f.cell_id) AS cell_count
FROM chart_feature f
WHERE f.dataset_id=%s
AND f.geometry_type IN ('POINT','LINE','AREA')
GROUP BY
f.object_class_id,
f.object_class,
f.geometry_type,
f.native_scale
ORDER BY
f.object_class,
f.geometry_type,
f.native_scale
""",
(a.dataset_id,),
)
grouped = list(cur.fetchall())
cur.execute(
"""
SELECT
object_class_id,
object_class,
geometry_code,
display_code,
description
FROM chart_dictionary_object
WHERE dataset_id=%s
""",
(a.dataset_id,),
)
dictionary_rows = list(cur.fetchall())
conn.rollback()
dictionary = {
int(r["object_class_id"]): r
for r in dictionary_rows
if r.get("object_class_id") is not None
}
variants: dict[tuple[str, str], dict[str, Any]] = {}
for row in grouped:
cls = str(row["object_class"])
geom = str(row["geometry_type"]).upper()
key = (cls, geom)
v = variants.setdefault(
key,
{
"object_class_id": (
int(row["object_class_id"])
if row.get("object_class_id") is not None
else None
),
"object_class": cls,
"geometry_type": geom,
"feature_count": 0,
"cell_count_max": 0,
"scale_counts": Counter(),
},
)
cnt = int(row["feature_count"])
v["feature_count"] += cnt
v["cell_count_max"] = max(v["cell_count_max"], int(row["cell_count"]))
v["scale_counts"][str(int(row["native_scale"]))] += cnt
gap_variants = []
for key, v in sorted(
variants.items(),
key=lambda kv: (-kv[1]["feature_count"], kv[0][0], kv[0][1]),
):
cls, geom = key
state = gap_reason(cls, geom, exact_rules, by_class)
if not state["is_gap"]:
continue
oid = v["object_class_id"]
d = dictionary.get(oid) if oid is not None else None
rule = state["rule"]
class_rules = by_class.get(cls, [])
item = {
"object_class_id": oid,
"object_class": cls,
"geometry_type": geom,
"feature_count": int(v["feature_count"]),
"cell_count": int(v["cell_count_max"]),
"scale_counts": dict(sorted(v["scale_counts"].items(), key=lambda x: int(x[0]))),
"gap_reason": state["reason"],
"dictionary_description": d.get("description") if d else None,
"dictionary_geometry_code": d.get("geometry_code") if d else None,
"dictionary_display_code": d.get("display_code") if d else None,
"exact_rule_id": str(rule.get("rule_id")) if rule else None,
"exact_rule_status": str(rule.get("mapping_status")) if rule else None,
"exact_rule_target": str(rule.get("target_source_layer")) if rule else None,
"exact_rule_safety": str(rule.get("safety_class")) if rule else None,
"class_known_geometry_rules": [
{
"geometry_type": str(r.get("geometry_type") or ""),
"rule_id": str(r.get("rule_id") or ""),
"target_source_layer": str(r.get("target_source_layer") or ""),
"mapping_status": str(r.get("mapping_status") or ""),
"safety_class": str(r.get("safety_class") or ""),
"canonical_object_type": str(r.get("canonical_object_type") or ""),
}
for r in class_rules
],
"inherited_hard_gate": bool(state["inherited_hard_gate"]),
"attribute_key_counts": {},
"attribute_value_samples": {},
"relation20_present": 0,
"relation20_missing": 0,
"sample_features": [],
}
gap_variants.append(item)
gap_keys = {(x["object_class"], x["geometry_type"]) for x in gap_variants}
gap_classes = sorted({x[0] for x in gap_keys})
# Gap rows are normally a small subset of Dataset. Read them once and
# build attribute/sample evidence in Python, avoiding dozens of SQL round trips.
if gap_classes:
placeholders = ",".join(["%s"] * len(gap_classes))
with connect() as conn:
try:
conn.cursor().execute("SET SESSION TRANSACTION READ ONLY")
except Exception:
pass
with conn.cursor() as cur:
cur.execute(
f"""
SELECT
f.id AS feature_id,
f.cell_id,
c.source_relative_path,
f.source_record_index,
f.object_class_id,
f.object_class,
f.geometry_type,
f.native_scale,
f.raw_geotype,
f.coverage_id,
f.bbox_min_lon,
f.bbox_min_lat,
f.bbox_max_lon,
f.bbox_max_lat,
f.attributes_json,
f.geometry_repaired,
f.geometry_issue
FROM chart_feature f
JOIN chart_cell c ON c.id=f.cell_id
WHERE f.dataset_id=%s
AND f.object_class IN ({placeholders})
AND f.geometry_type IN ('POINT','LINE','AREA')
ORDER BY
f.object_class,
f.geometry_type,
f.native_scale,
c.source_relative_path,
f.source_record_index
""",
(a.dataset_id, *gap_classes),
)
source_rows = list(cur.fetchall())
conn.rollback()
else:
source_rows = []
item_by_key = {
(x["object_class"], x["geometry_type"]): x
for x in gap_variants
}
key_counters: dict[tuple[str, str], Counter] = defaultdict(Counter)
value_counters: dict[tuple[str, str, str], Counter] = defaultdict(Counter)
sample_counts: Counter = Counter()
sample_jsonl_path = outdir / "cm95_global_mapping_gap_samples_v1.jsonl"
with sample_jsonl_path.open("w", encoding="utf-8") as sample_file:
for row in source_rows:
key = (
str(row["object_class"]),
str(row["geometry_type"]).upper(),
)
item = item_by_key.get(key)
if item is None:
continue
attrs = json_dict(row.get("attributes_json"))
for attr_key, attr_value in attrs.items():
key_counters[key][str(attr_key)] += 1
value_counters[(key[0], key[1], str(attr_key))][scalar_text(attr_value)] += 1
rel = attrs.get("_cm93_relation20_raw")
if rel is None or rel == "":
item["relation20_missing"] += 1
else:
item["relation20_present"] += 1
if sample_counts[key] < max(1, int(a.sample_per_variant)):
sample_counts[key] += 1
sample = {
"feature_id": int(row["feature_id"]),
"cell_id": int(row["cell_id"]),
"source_relative_path": str(row["source_relative_path"]),
"source_record_index": int(row["source_record_index"]),
"object_class_id": (
int(row["object_class_id"])
if row.get("object_class_id") is not None
else None
),
"object_class": key[0],
"geometry_type": key[1],
"native_scale": int(row["native_scale"]),
"raw_geotype": row.get("raw_geotype"),
"coverage_id": row.get("coverage_id"),
"bbox": [
row.get("bbox_min_lon"),
row.get("bbox_min_lat"),
row.get("bbox_max_lon"),
row.get("bbox_max_lat"),
],
"attributes": attrs,
"geometry_repaired": bool(row.get("geometry_repaired")),
"geometry_issue": row.get("geometry_issue"),
}
item["sample_features"].append(sample)
sample_file.write(
json.dumps(sample, ensure_ascii=False, separators=(",", ":"), default=str)
+ "\n"
)
for item in gap_variants:
key = (item["object_class"], item["geometry_type"])
item["attribute_key_counts"] = dict(
key_counters[key].most_common()
)
values = {}
for attr_key, _count in key_counters[key].most_common():
values[attr_key] = [
{"value": value, "count": count}
for value, count in value_counters[
(key[0], key[1], attr_key)
].most_common(max(1, int(a.attribute_value_limit)))
]
item["attribute_value_samples"] = values
reason_counts = Counter()
gap_feature_total = 0
no_exact_total = 0
explicit_total = 0
inherited_hard_gate_total = 0
for item in gap_variants:
reason_counts[item["gap_reason"]] += 1
gap_feature_total += int(item["feature_count"])
if item["gap_reason"].startswith("NO_EXACT_RULE"):
no_exact_total += int(item["feature_count"])
else:
explicit_total += int(item["feature_count"])
if item["inherited_hard_gate"]:
inherited_hard_gate_total += int(item["feature_count"])
report = {
"schema": "navsea.cm95.global-mapping-gap-audit/v1",
"dataset_id": int(a.dataset_id),
"mapping_path": str(mapping_path),
"source_variant_count": len(variants),
"gap_variant_count": len(gap_variants),
"gap_object_class_count": len({x["object_class"] for x in gap_variants}),
"gap_feature_total": gap_feature_total,
"no_exact_rule_feature_total": no_exact_total,
"explicit_engineering_or_status_gap_feature_total": explicit_total,
"inherited_hard_gate_feature_total": inherited_hard_gate_total,
"gap_reason_counts": dict(reason_counts),
"gap_variants": gap_variants,
}
output_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
csv_path = outdir / "cm95_global_mapping_gap_variants_v1.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
fields = [
"object_class_id",
"object_class",
"geometry_type",
"feature_count",
"cell_count",
"gap_reason",
"inherited_hard_gate",
"dictionary_description",
"dictionary_geometry_code",
"dictionary_display_code",
"exact_rule_id",
"exact_rule_status",
"exact_rule_target",
"exact_rule_safety",
"scale_counts",
"known_geometry_rules",
"attribute_keys",
"relation20_present",
"relation20_missing",
]
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for item in gap_variants:
w.writerow(
{
"object_class_id": item["object_class_id"],
"object_class": item["object_class"],
"geometry_type": item["geometry_type"],
"feature_count": item["feature_count"],
"cell_count": item["cell_count"],
"gap_reason": item["gap_reason"],
"inherited_hard_gate": item["inherited_hard_gate"],
"dictionary_description": item["dictionary_description"],
"dictionary_geometry_code": item["dictionary_geometry_code"],
"dictionary_display_code": item["dictionary_display_code"],
"exact_rule_id": item["exact_rule_id"],
"exact_rule_status": item["exact_rule_status"],
"exact_rule_target": item["exact_rule_target"],
"exact_rule_safety": item["exact_rule_safety"],
"scale_counts": json.dumps(item["scale_counts"], ensure_ascii=False, separators=(",", ":")),
"known_geometry_rules": json.dumps(item["class_known_geometry_rules"], ensure_ascii=False, separators=(",", ":")),
"attribute_keys": json.dumps(item["attribute_key_counts"], ensure_ascii=False, separators=(",", ":")),
"relation20_present": item["relation20_present"],
"relation20_missing": item["relation20_missing"],
}
)
txt_path = outdir / "cm95_global_mapping_gap_summary_v1.txt"
lines = [
"CM95 全 Dataset → NavSea delivery Mapping Gap 审计 v1",
"=" * 76,
f"Dataset ID : {a.dataset_id}",
f"Source variants : {len(variants)}",
f"Gap variants : {len(gap_variants)}",
f"Gap object classes : {report['gap_object_class_count']}",
f"Gap feature rows : {gap_feature_total}",
f"No exact rule rows : {no_exact_total}",
f"Explicit/status gap rows: {explicit_total}",
f"Inherited hard-gate rows: {inherited_hard_gate_total}",
"",
"Top gap variants:",
]
for item in gap_variants[:80]:
scales = ",".join(
f"{k}:{v}" for k, v in item["scale_counts"].items()
)
lines.append(
f" {item['object_class']:<10} {item['geometry_type']:<5} "
f"rows={item['feature_count']:<6} "
f"reason={item['gap_reason']:<34} "
f"hard={str(item['inherited_hard_gate']):<5} "
f"scales=[{scales}]"
)
lines += [
"",
f"JSON : {output_path}",
f"CSV : {csv_path}",
f"SAMPLES: {sample_jsonl_path}",
]
txt_path.write_text("\n".join(lines), encoding="utf-8")
print("\n".join(lines))
if __name__ == "__main__":
main()