Files
CM95/tools/cm95_pbf_style_contract_audit_v2.py

191 lines
6.4 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 -*-
"""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()