#!/usr/bin/env python3 # -*- coding: utf-8 -*- """CM95 delivery PBF → NavSea style 消费契约审计 v2。 检查每个 delivery feature: 1. source-layer 是否存在于 style; 2. 属性值是否至少能命中一个 style filter(忽略 min/max zoom); 3. 当前 zoom 是否实际可见; 4. 将“契约不匹配”和“因 minzoom/maxzoom 有意隐藏”分开统计。 适用于当前 NavSea unified style 使用的 filter 表达式子集。 """ from __future__ import annotations import argparse import json from collections import Counter, defaultdict from pathlib import Path from typing import Any import mapbox_vector_tile def parse_args(): p = argparse.ArgumentParser() p.add_argument("--pbf-root", required=True) p.add_argument("--style", required=True) p.add_argument("--output", default="cm95_pbf_style_contract_audit_v2.json") return p.parse_args() def ev(expr: Any, props: dict[str, Any]): if not isinstance(expr, list): return expr if not expr: return None op = expr[0] if op == "get": return props.get(expr[1]) if op == "has": return expr[1] in props if op == "!": return not bool(ev(expr[1], props)) if op == "all": return all(bool(ev(x, props)) for x in expr[1:]) if op == "any": return any(bool(ev(x, props)) for x in expr[1:]) if op == "==": return ev(expr[1], props) == ev(expr[2], props) if op == "!=": return ev(expr[1], props) != ev(expr[2], props) if op == "slice": value = ev(expr[1], props) if value is None: return None start = int(ev(expr[2], props)) end = int(ev(expr[3], props)) if len(expr) > 3 else None return str(value)[start:end] if op == "to-number": value = ev(expr[1], props) try: num = float(value) return int(num) if num.is_integer() else num except Exception: return 0 if op == "literal": return expr[1] if op == "match": value = ev(expr[1], props) pairs = expr[2:-1] default = expr[-1] for i in range(0, len(pairs), 2): label = pairs[i] output = pairs[i + 1] if isinstance(label, list) and label and label[0] == "literal": labels = label[1] if value in labels: return ev(output, props) elif isinstance(label, list): if value in label: return ev(output, props) elif value == label: return ev(output, props) return ev(default, props) raise RuntimeError(f"unsupported style filter op: {op}") def decode_layers(path: Path): decoded = mapbox_vector_tile.decode(path.read_bytes()) for name, layer in decoded.items(): yield name, layer def main(): a = parse_args() pbf_root = Path(a.pbf_root) style = json.loads(Path(a.style).read_text(encoding="utf-8")) style_by_source_layer = defaultdict(list) for layer in style.get("layers", []): if layer.get("source") == "navsea_delivery" and layer.get("source-layer"): style_by_source_layer[layer["source-layer"]].append(layer) files = sorted(pbf_root.rglob("*.pbf")) contract_unmatched = Counter() zoom_hidden = Counter() visible = Counter() examples = defaultdict(list) total_features = 0 for path in files: try: rel = path.relative_to(pbf_root) z = int(rel.parts[-3]) except Exception: raise RuntimeError(f"PBF path must end with z/x/y.pbf: {path}") for source_layer, layer in decode_layers(path): style_layers = style_by_source_layer.get(source_layer, []) for feature in layer.get("features", []): total_features += 1 props = feature.get("properties") or {} filter_matches = [] for sl in style_layers: flt = sl.get("filter") if flt is None or bool(ev(flt, props)): filter_matches.append(sl) key = (z, source_layer) if not filter_matches: contract_unmatched[key] += 1 if len(examples[("contract", key)]) < 10: examples[("contract", key)].append(props) continue zoom_matches = [] for sl in filter_matches: minz = sl.get("minzoom") maxz = sl.get("maxzoom") if minz is not None and z < minz: continue if maxz is not None and z >= maxz: continue zoom_matches.append(sl) if zoom_matches: visible[key] += 1 else: zoom_hidden[key] += 1 if len(examples[("zoom", key)]) < 10: examples[("zoom", key)].append(props) result = { "schema": "navsea.cm95.style-contract-audit/v2", "pbf_file_count": len(files), "feature_count": total_features, "style_source_layer_count": len(style_by_source_layer), "contract_unmatched_count": sum(contract_unmatched.values()), "zoom_hidden_count": sum(zoom_hidden.values()), "visible_count": sum(visible.values()), "contract_unmatched": [ {"z": z, "source_layer": sl, "count": count, "examples": examples.get(("contract", (z, sl)), [])} for (z, sl), count in sorted(contract_unmatched.items()) ], "zoom_hidden": [ {"z": z, "source_layer": sl, "count": count, "examples": examples.get(("zoom", (z, sl)), [])} for (z, sl), count in sorted(zoom_hidden.items()) ], } result["overall"] = "PASS" if result["contract_unmatched_count"] == 0 else "FAIL" Path(a.output).write_text( json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"PBF files : {result['pbf_file_count']}") print(f"Features : {result['feature_count']}") print(f"Contract unmatched : {result['contract_unmatched_count']}") print(f"Zoom-hidden (expected) : {result['zoom_hidden_count']}") print(f"Visible : {result['visible_count']}") print(f"OVERALL : {result['overall']}") if __name__ == "__main__": main()