#!/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()