from __future__ import annotations import argparse import json from pathlib import Path from cm93db.config import load_config from .builder import Cm95TileBuilder, TileJob from .identity import build_manifest from .policy import child_tiles from .production import ( create_production_plan, migrate_production_release, parse_zooms, production_status, run_production_plan, ) from .release import finalize_release DEFAULT_MAPPING = Path("config/mappings/cm95_to_navsea_v1.yaml") DEFAULT_POLICY = Path("config/pbf/cm95_tile_policy_v1.yaml") DEFAULT_MANIFEST = Path("build/cm95_fid_manifest_v1.sqlite") def _add_common_db(p): p.add_argument("--dataset-id", type=int, default=1) def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="cm95pbf", description="CM95 MySQL → NavSea-compatible MVT/PBF pilot generator", ) sub = p.add_subparsers(dest="command", required=True) manifest = sub.add_parser( "build-fid-manifest", help="建立全 Dataset 确定性 int32 MVT Feature ID 清单", ) _add_common_db(manifest) manifest.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) manifest.add_argument("--rebuild-fid-manifest", action="store_true") tile = sub.add_parser( "build-tile", help="生成单张 CM95 NavSea-compatible PBF", ) _add_common_db(tile) tile.add_argument("--z", type=int, required=True) tile.add_argument("--x", type=int, required=True) tile.add_argument("--y", type=int, required=True) tile.add_argument("--profile", choices=["engineering", "delivery"], default="delivery") tile.add_argument("--mapping", default=str(DEFAULT_MAPPING)) tile.add_argument("--policy", default=str(DEFAULT_POLICY)) tile.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) tile.add_argument("--output-root", default="build/pbf") tile.add_argument("--report-root", default="build/pbf-reports") sample = sub.add_parser( "build-sample-aoi", help="生成 z10 parent + z11 children + z12 grandchildren,共 21 张测试 tile", ) _add_common_db(sample) sample.add_argument("--z10-x", type=int, default=914) sample.add_argument("--z10-y", type=int, default=385) sample.add_argument( "--profiles", default="engineering,delivery", help="逗号分隔:engineering,delivery", ) sample.add_argument("--mapping", default=str(DEFAULT_MAPPING)) sample.add_argument("--policy", default=str(DEFAULT_POLICY)) sample.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) sample.add_argument("--output-root", default="build/pbf") sample.add_argument("--report-root", default="build/pbf-reports") prod_plan = sub.add_parser( "plan-production", help="按 Dataset coverage 枚举生产 tile,并冻结不可变生成计划", ) _add_common_db(prod_plan) prod_plan.add_argument("--zooms", default="0-12", help="缩放级别,支持 0-12 或 0,1,...,12") prod_plan.add_argument("--profile", choices=["engineering", "delivery"], default="delivery") prod_plan.add_argument("--mapping", default=str(DEFAULT_MAPPING)) prod_plan.add_argument("--policy", default=str(DEFAULT_POLICY)) prod_plan.add_argument("--manifest", default=str(DEFAULT_MANIFEST)) prod_plan.add_argument("--output", default="build/production/cm95_production_plan_v1.json") prod_build = sub.add_parser( "build-production", help="按冻结计划批量生成,支持断点续跑、并发和失败重试", ) prod_build.add_argument("--plan", default="build/production/cm95_production_plan_v1.json") prod_build.add_argument("--release-root", default="build/releases") prod_build.add_argument("--workers", type=int, default=4) prod_build.add_argument("--max-attempts", type=int, default=3) prod_migrate = sub.add_parser( "migrate-production-release", help="把旧 release 的 delivery-equivalent PASS tile 复用到新计划,只重建受影响/失败任务(支持 builder contract 升级)", ) prod_migrate.add_argument("--old-release-dir", required=True) prod_migrate.add_argument("--new-plan", default="build/production/cm95_production_plan_v1_4_5.json") prod_migrate.add_argument("--release-root", default="build/releases") prod_migrate.add_argument("--copy-mode", choices=["hardlink", "copy"], default="hardlink") prod_migrate.add_argument( "--invalidate-tiles-file", default=None, help="每行一个 z/x/y;这些旧 PASS tile 强制留为 PENDING,适用于 builder contract 的定向重建", ) prod_status = sub.add_parser( "status-production", help="查看生产计划当前账本状态", ) prod_status.add_argument("--plan", default="build/production/cm95_production_plan_v1.json") prod_status.add_argument("--release-root", default="build/releases") prod_final = sub.add_parser( "finalize-production", help="对完成的生产产物做三方计数/FID/哈希盘点并生成 release manifest", ) prod_final.add_argument("--plan", default="build/production/cm95_production_plan_v1.json") prod_final.add_argument("--release-root", default="build/releases") prod_final.add_argument("--style", default="config/style/style.navsea-delivery-full-semantic-unified-20260808-cm95-v1.json") prod_final.add_argument("--sprite-json", default="config/sprite/semantic-sprite-20260808.json") prod_final.add_argument("--gap-disposition", default="config/mappings/cm95_gap_disposition_v1.yaml") return p def ensure_manifest(dataset_id: int, manifest_path: Path) -> dict: cfg = load_config() with __import__("pymysql").connect( host=cfg.host, port=cfg.port, user=cfg.user, password=cfg.password, database=cfg.database, charset=cfg.charset, cursorclass=__import__("pymysql").cursors.DictCursor, autocommit=False, connect_timeout=20, read_timeout=900, ) as conn: return build_manifest( conn, dataset_id, manifest_path, rebuild=False, ) def main() -> None: args = build_parser().parse_args() cfg = load_config() if args.command == "plan-production": manifest_path = Path(args.manifest) if not manifest_path.exists(): print("FID manifest 不存在,先自动建立。") print(json.dumps(ensure_manifest(args.dataset_id, manifest_path), ensure_ascii=False, indent=2)) result = create_production_plan( db=cfg, dataset_id=args.dataset_id, mapping_path=Path(args.mapping), policy_path=Path(args.policy), manifest_path=manifest_path, output_path=Path(args.output), zooms=parse_zooms(args.zooms), profile=args.profile, ) print(json.dumps({ "plan_id": result["plan_id"], "release_id": result["release_id"], "job_count": result["job_count"], "source_stats": result["source_stats"], "output": str(Path(args.output)), }, ensure_ascii=False, indent=2)) return if args.command == "build-production": result = run_production_plan( db=cfg, plan_path=Path(args.plan), release_root=Path(args.release_root), workers=args.workers, max_attempts=args.max_attempts, ) print(json.dumps(result, ensure_ascii=False, indent=2)) states = result.get("states") or {} if states.get("FAILED", 0) or states.get("PENDING", 0) or states.get("RUNNING", 0): raise SystemExit(2) return if args.command == "migrate-production-release": result = migrate_production_release( old_release_dir=Path(args.old_release_dir), new_plan_path=Path(args.new_plan), release_root=Path(args.release_root), copy_mode=args.copy_mode, invalidate_tiles_file=(Path(args.invalidate_tiles_file) if args.invalidate_tiles_file else None), ) print(json.dumps(result, ensure_ascii=False, indent=2)) return if args.command == "status-production": print(json.dumps(production_status(Path(args.plan), Path(args.release_root)), ensure_ascii=False, indent=2)) return if args.command == "finalize-production": result = finalize_release( Path(args.plan), Path(args.release_root), Path(args.style) if args.style else None, Path(args.sprite_json) if args.sprite_json else None, Path(args.gap_disposition) if args.gap_disposition else None, ) print(json.dumps({ "status": result["status"], "release_id": result["release_id"], "planned_tile_jobs": result["planned_tile_jobs"], "pbf_file_count": result["pbf_file_count"], "planned_output_features": result["planned_output_features"], "decoded_pbf_features": result["decoded_pbf_features"], "semantic_completeness": result.get("semantic_completeness"), "semantic_backlog": result.get("semantic_backlog"), "non_renderable_omissions": result.get("non_renderable_omissions"), "non_renderable_omission_layers": result.get("non_renderable_omission_layers"), "style_sprite_contract": { "overall": (result.get("style_sprite_contract") or {}).get("overall"), "contract_unmatched_count": (result.get("style_sprite_contract") or {}).get("contract_unmatched_count"), "sprite_missing_count": (result.get("style_sprite_contract") or {}).get("sprite_missing_count"), }, "quantization_fallbacks": result.get("quantization_fallbacks"), "quantization_omissions": result.get("quantization_omissions"), "nav_name_conflict_audit": { "overall": (result.get("nav_name_conflict_audit") or {}).get("overall"), "policy": (result.get("nav_name_conflict_audit") or {}).get("policy"), "report_occurrences": (result.get("nav_name_conflict_audit") or {}).get("report_occurrences"), "trace_occurrences": (result.get("nav_name_conflict_audit") or {}).get("trace_occurrences"), "safe_omission_occurrences": (result.get("nav_name_conflict_audit") or {}).get("safe_omission_occurrences"), "unique_conflict_outputs": (result.get("nav_name_conflict_audit") or {}).get("unique_conflict_outputs"), "affected_tiles": (result.get("nav_name_conflict_audit") or {}).get("affected_tiles"), }, "failures": result["failures"], }, ensure_ascii=False, indent=2)) if result["status"] != "PASS": raise SystemExit(2) return if args.command == "build-fid-manifest": import pymysql with 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, ) as conn: result = build_manifest( conn, args.dataset_id, Path(args.manifest), rebuild=bool(args.rebuild_fid_manifest), ) print(json.dumps(result, ensure_ascii=False, indent=2)) return manifest_path = Path(args.manifest) if not manifest_path.exists(): print("FID manifest 不存在,先自动建立。") print( json.dumps( ensure_manifest(args.dataset_id, manifest_path), ensure_ascii=False, indent=2, ) ) if args.command == "build-tile": builder = Cm95TileBuilder( db=cfg, dataset_id=args.dataset_id, mapping_path=Path(args.mapping), policy_path=Path(args.policy), fid_manifest_path=manifest_path, output_root=Path(args.output_root) / args.profile, report_root=Path(args.report_root), profile=args.profile, ) result = builder.build_tile( TileJob(args.z, args.x, args.y) ) print(json.dumps(result, ensure_ascii=False, indent=2)) return if args.command == "build-sample-aoi": profiles = [ token.strip() for token in str(args.profiles).split(",") if token.strip() ] for profile in profiles: if profile not in {"engineering", "delivery"}: raise SystemExit(f"invalid profile: {profile}") jobs = [(10, args.z10_x, args.z10_y)] jobs.extend(child_tiles(10, args.z10_x, args.z10_y, 11)) jobs.extend(child_tiles(10, args.z10_x, args.z10_y, 12)) results = [] for profile in profiles: builder = Cm95TileBuilder( db=cfg, dataset_id=args.dataset_id, mapping_path=Path(args.mapping), policy_path=Path(args.policy), fid_manifest_path=manifest_path, output_root=Path(args.output_root) / profile, report_root=Path(args.report_root), profile=profile, ) for z, x, y in jobs: result = builder.build_tile(TileJob(z, x, y)) results.append(result) print( f"[{profile}] {z}/{x}/{y} " f"features={result['output_feature_count']} " f"layers={result['output_layer_count']} " f"hard_gate_unmapped={len(result['hard_gate_unmapped'])}" ) summary = { "dataset_id": args.dataset_id, "root_tile": f"10/{args.z10_x}/{args.z10_y}", "profiles": profiles, "tile_count_per_profile": len(jobs), "results": results, } summary_path = Path(args.report_root) / "sample_aoi_summary_v1.json" summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text( json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8", ) print(f"SUMMARY: {summary_path}") return if __name__ == "__main__": main()