416 lines
26 KiB
Python
416 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""从 Provider manifest 运行正式 PBF Pipeline,终点为 NavSea Provider Package。"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import hashlib
|
||
import importlib.util
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import urllib.parse
|
||
import urllib.request
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT / "tools" / "provider-policy-compiler"))
|
||
from provider_policy import build_package, build_policy, create_delivery_zip, fingerprint, validate_package, validate_policy, export_package
|
||
|
||
|
||
def read_resource(location: str, timeout: int = 60) -> bytes:
|
||
if location.startswith(("http://", "https://")):
|
||
request = urllib.request.Request(location, headers={"User-Agent": "PBF-Provider-Pipeline/1.0"})
|
||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||
return response.read()
|
||
return Path(location).expanduser().read_bytes()
|
||
|
||
|
||
def resolved(base: str, relative: str) -> str:
|
||
if relative.startswith(("http://", "https://")):
|
||
return relative
|
||
if base.startswith(("http://", "https://")):
|
||
return urllib.parse.urljoin(base, relative)
|
||
return str((Path(base).expanduser().parent / relative).resolve())
|
||
|
||
|
||
def sha(data: bytes) -> str:
|
||
return hashlib.sha256(data).hexdigest()
|
||
|
||
|
||
def snapshot(location: str, target: Path) -> dict[str, Any]:
|
||
data = read_resource(location)
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(data)
|
||
return {"uri": location, "snapshot": str(target), "sha256": sha(data), "bytes": len(data), "available": True}
|
||
|
||
|
||
def source_tile_template(style: dict[str, Any], configured: str | None, source_id: str | None) -> str:
|
||
if configured:
|
||
return configured
|
||
sources = style.get("sources", {})
|
||
ordered = [sources[source_id]] if source_id in sources else list(sources.values())
|
||
for source in ordered:
|
||
if source.get("type") == "vector" and source.get("tiles"):
|
||
return source["tiles"][0]
|
||
raise ValueError("Style 中没有可用的 vector tile template,且 manifest 未提供 tileSource.template")
|
||
|
||
|
||
def sample_one(template: str, samples: list[str], destination: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||
successes: list[dict[str, Any]] = []
|
||
failures: list[dict[str, Any]] = []
|
||
for sample in samples:
|
||
tile = sample.strip().strip("/")
|
||
url = template.replace("{z}", tile.split("/")[0]).replace("{x}", tile.split("/")[1]).replace("{y}", tile.split("/")[2])
|
||
target = destination / (tile + ".pbf")
|
||
try:
|
||
item = snapshot(url, target)
|
||
item.update({"tile": tile})
|
||
if not item["bytes"]:
|
||
raise ValueError("空 PBF 响应")
|
||
successes.append(item)
|
||
except Exception as exc: # each configured candidate is independently auditable
|
||
failures.append({"tile": tile, "uri": url, "error": f"{type(exc).__name__}: {exc}"})
|
||
return successes, failures
|
||
|
||
|
||
def standard_assignment(actual: dict[str, Any], standard: dict[str, Any]) -> list[dict[str, Any]]:
|
||
by_visual: dict[tuple[str, str], set[str]] = {}
|
||
ref_by_visual: dict[tuple[str, str], set[str]] = {}
|
||
for obj in standard.get("visual_objects", []):
|
||
layer = obj.get("standard_layer_name")
|
||
vhash = obj.get("visual_hash")
|
||
role = obj.get("component_role")
|
||
if layer and vhash and role:
|
||
by_visual.setdefault((role, vhash), set()).add(layer)
|
||
ref_by_visual.setdefault((role, vhash), set()).add(obj.get("visual_object_id"))
|
||
rows = []
|
||
for obj in actual.get("visual_objects", []):
|
||
candidates: set[str] = set()
|
||
refs: set[str] = set()
|
||
for component in obj.get("components", []):
|
||
role = {"icon": "icon", "fill": "fill-pattern", "line": "line-pattern"}.get(component.get("role"), component.get("role"))
|
||
vhash = component.get("resource_visual_hash")
|
||
if role and vhash:
|
||
candidates.update(by_visual.get((role, vhash), set()))
|
||
refs.update(ref_by_visual.get((role, vhash), set()))
|
||
exact_ref = next(iter(refs)) if len(candidates) == 1 and len(refs) == 1 else None
|
||
rows.append(_assignment(obj, sorted(candidates), "FROZEN_STANDARD_VISUAL_HASH", exact_ref))
|
||
return rows
|
||
|
||
|
||
def _assignment(obj: dict[str, Any], candidates: list[str], basis: str, standard_visual_object_ref: str | None = None) -> dict[str, Any]:
|
||
if len(candidates) == 1:
|
||
status, layer = ("VISUAL_OBJECT_MATCH", candidates[0]) if standard_visual_object_ref else ("LAYER_ONLY", candidates[0])
|
||
elif len(candidates) > 1:
|
||
status, layer = "AMBIGUOUS", None
|
||
else:
|
||
status, layer = "UNKNOWN", None
|
||
return {"visualObjectId": obj["visual_object_id"], "occurrenceIds": [], "status": status, "standardLayer": layer, "standardVisualObjectRef": standard_visual_object_ref, "candidateStandardLayers": candidates if status == "AMBIGUOUS" else [], "basis": basis, "provenance": obj.get("provenance", {})}
|
||
|
||
|
||
def manual_assignment(actual: dict[str, Any], assignment_doc: dict[str, Any]) -> list[dict[str, Any]]:
|
||
resource_lookup = {}
|
||
for row in assignment_doc.get("resource_summary", []):
|
||
resource_lookup[row.get("cm95_resource_key")] = row
|
||
object_by_id = {x["visual_object_id"]: x for x in actual.get("visual_objects", [])}
|
||
components_by_vo: dict[str, set[str]] = {}
|
||
for obj in actual.get("visual_objects", []):
|
||
components_by_vo[obj["visual_object_id"]] = {c.get("resource_key") for c in obj.get("components", []) if c.get("resource_key")}
|
||
rows = []
|
||
for vo_id, obj in object_by_id.items():
|
||
evidence = [resource_lookup[key] for key in components_by_vo.get(vo_id, set()) if key in resource_lookup]
|
||
layer_candidates = sorted({layer for e in evidence for layer in ([e.get("assigned_standard_layer")] + (e.get("candidate_standard_layers") or [])) if layer})
|
||
statuses = {e.get("assignment_status") for e in evidence}
|
||
if not evidence or not layer_candidates:
|
||
status, layer, candidates = "UNKNOWN", None, []
|
||
elif "AMBIGUOUS" in statuses or len(layer_candidates) > 1:
|
||
status, layer, candidates = "AMBIGUOUS", None, layer_candidates
|
||
elif statuses == {"COMPONENT"}:
|
||
status, layer, candidates = "COMPONENT_ONLY", None, []
|
||
else:
|
||
status, layer, candidates = "LAYER_ONLY", layer_candidates[0], []
|
||
exact_resources = {x.get("reviewed_standard_resource_key") for x in evidence if x.get("assignment_status") == "MATCH" and x.get("reviewed_standard_resource_key")}
|
||
exact_ref = next(iter(exact_resources)) if len(evidence) == 1 and len(exact_resources) == 1 else None
|
||
if status == "LAYER_ONLY" and exact_ref:
|
||
status = "VISUAL_OBJECT_MATCH"
|
||
rows.append({"visualObjectId": vo_id, "occurrenceIds": [], "status": status, "standardLayer": layer, "standardVisualObjectRef": exact_ref, "candidateStandardLayers": candidates, "basis": "HUMAN_REVIEWED_ASSIGNMENT_RESOURCE_KEY", "manualStatuses": sorted(x for x in statuses if x), "provenance": obj.get("provenance", {})})
|
||
return rows
|
||
|
||
|
||
def load_execution_catalog(manifest_path: Path, relative: str, style_hash: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
catalog_path = (manifest_path.parent / relative).resolve()
|
||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||
if catalog.get("schema") != "actual-visual-object-catalog/v1" or not isinstance(catalog.get("visual_objects"), list):
|
||
raise ValueError("executionEvidenceCatalog schema 无效")
|
||
style_path = Path(catalog.get("inputs", {}).get("style", ""))
|
||
if not style_path.is_file():
|
||
raise ValueError("executionEvidenceCatalog Style 输入不存在")
|
||
catalog_style_hash = sha(style_path.read_bytes())
|
||
if catalog_style_hash != style_hash:
|
||
raise ValueError("executionEvidenceCatalog Style SHA-256 与 intake Style 不一致")
|
||
summary = catalog.get("summary", {})
|
||
source = {
|
||
"schema": catalog["schema"], "catalog": relative,
|
||
"styleSha256": catalog_style_hash,
|
||
"tileCount": catalog.get("inputs", {}).get("tile_count"),
|
||
"matchedOccurrenceCount": summary.get("matched_feature_occurrence_count"),
|
||
"distinctVisualObjectCount": summary.get("distinct_visual_object_count"),
|
||
}
|
||
return catalog, source
|
||
|
||
|
||
def select_mapping_catalog(
|
||
sample_catalog: dict[str, Any], execution_catalog: dict[str, Any] | None
|
||
) -> dict[str, Any]:
|
||
"""Use the declared, Style-verified execution catalog for complete mapping.
|
||
|
||
The sample catalog remains the sole source of sample occurrenceIds. If the
|
||
full catalog does not contain every sampled Visual Object, fail closed.
|
||
"""
|
||
if execution_catalog is None:
|
||
return sample_catalog
|
||
full_objects = execution_catalog.get("visual_objects")
|
||
sample_objects = sample_catalog.get("visual_objects")
|
||
if not isinstance(full_objects, list) or not isinstance(sample_objects, list):
|
||
raise ValueError("映射 Catalog 缺少 visual_objects")
|
||
full_ids = [item.get("visual_object_id") for item in full_objects if isinstance(item, dict)]
|
||
sample_ids = [item.get("visual_object_id") for item in sample_objects if isinstance(item, dict)]
|
||
if (len(full_ids) != len(full_objects) or
|
||
len(sample_ids) != len(sample_objects) or
|
||
any(not isinstance(item, str) or not item for item in full_ids + sample_ids) or
|
||
len(set(full_ids)) != len(full_ids) or
|
||
len(set(sample_ids)) != len(sample_ids)):
|
||
raise ValueError("映射 Catalog Visual Object ID 无效或重复")
|
||
missing = set(sample_ids) - set(full_ids)
|
||
if missing:
|
||
raise ValueError(f"全量映射 Catalog 未覆盖样本 Visual Object:{len(missing)} 条")
|
||
return execution_catalog
|
||
|
||
|
||
def attach_render_evidence(assignments: list[dict[str, Any]], catalog: dict[str, Any]) -> list[str]:
|
||
objects = {obj["visual_object_id"]: obj for obj in catalog["visual_objects"]}
|
||
missing = []
|
||
for row in assignments:
|
||
obj = objects.get(row["visualObjectId"])
|
||
if obj is None:
|
||
row["renderEvidence"] = None
|
||
missing.append(row["visualObjectId"])
|
||
continue
|
||
row["renderEvidence"] = {
|
||
"sourceLayer": obj.get("provenance", {}).get("source_layer"),
|
||
"geometryRole": obj.get("geometry_role"),
|
||
"occurrenceCount": obj.get("occurrence_count"),
|
||
"components": [{
|
||
"styleLayerId": component.get("style_layer_id"),
|
||
"styleIndex": component.get("style_index"),
|
||
"role": component.get("role"),
|
||
"resourceKey": component.get("resource_key"),
|
||
"resourceVisualHash": component.get("resource_visual_hash"),
|
||
} for component in obj.get("components", [])],
|
||
}
|
||
return missing
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--manifest", required=True, help="Provider manifest JSON")
|
||
ap.add_argument("--work-dir", help="Defaults to .tmp/provider-pipeline/<provider>/<dataset>")
|
||
ap.add_argument("--output-dir", help="Defaults to dist/<provider>/<dataset>/<version>")
|
||
ap.add_argument("--sample-tile", action="append", help="XYZ tile z/x/y; repeat to supply candidates")
|
||
ap.add_argument("--no-export", action="store_true", help="Run validation without exporting a package")
|
||
args = ap.parse_args()
|
||
manifest_path = Path(args.manifest).resolve()
|
||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
provider = manifest["provider"]
|
||
dataset = manifest["dataset"]
|
||
package = manifest["package"]
|
||
provider_id, dataset_id, version = provider["providerId"], dataset["datasetId"], package["version"]
|
||
work = Path(args.work_dir).resolve() if args.work_dir else ROOT / ".tmp" / "provider-pipeline" / provider_id / dataset_id
|
||
export_dir = Path(args.output_dir).resolve() if args.output_dir else ROOT / "dist" / provider_id / dataset_id / version
|
||
record: dict[str, Any] = {"manifest": str(manifest_path), "stages": {}, "provider": provider, "dataset": dataset, "package": package}
|
||
|
||
intake = work / "intake"
|
||
intake.mkdir(parents=True, exist_ok=True)
|
||
style_spec = manifest["resources"]["style"]
|
||
style_uri = style_spec["uri"]
|
||
style_info = snapshot(style_uri, intake / "style.snapshot.json")
|
||
style = json.loads((intake / "style.snapshot.json").read_text(encoding="utf-8"))
|
||
template = source_tile_template(style, manifest.get("tileSource", {}).get("template"), manifest.get("tileSource", {}).get("sourceId"))
|
||
tile_source = {"uriTemplate": template, "sourceId": manifest.get("tileSource", {}).get("sourceId"), "protocol": "XYZ"}
|
||
sprite_base = manifest.get("resources", {}).get("sprite", {}).get("uri") or style.get("sprite")
|
||
if not sprite_base:
|
||
sprite_info = {"available": False, "uri": None, "reason": "Style 未声明 Sprite"}
|
||
else:
|
||
sprite_base = resolved(style_uri, sprite_base) if not sprite_base.startswith(("http://", "https://")) else sprite_base
|
||
sprite_dir = intake / "sprite"
|
||
sprite_dir.mkdir(parents=True, exist_ok=True)
|
||
sprite_info = snapshot(sprite_base + ".json", sprite_dir / "sprite.json")
|
||
png_info = snapshot(sprite_base + ".png", sprite_dir / "sprite.png")
|
||
sprite_info["png"] = png_info
|
||
glyph_template = manifest.get("resources", {}).get("glyphs", {}).get("uri") or style.get("glyphs")
|
||
glyph_info = {"uriTemplate": glyph_template, "available": bool(glyph_template), "snapshot": None}
|
||
if not glyph_template:
|
||
glyph_info["reason"] = "Style 未声明 Glyph 资源"
|
||
if glyph_template and manifest.get("probeGlyph", False):
|
||
probe = glyph_template.replace("{fontstack}", urllib.parse.quote(manifest.get("glyphProbe", {}).get("fontstack", "Noto Sans Regular"), safe="")).replace("{range}", manifest.get("glyphProbe", {}).get("range", "0-255"))
|
||
try:
|
||
glyph_info.update(snapshot(probe, intake / "glyphs" / "probe.pbf"))
|
||
except Exception as exc:
|
||
glyph_info.update({"available": False, "error": str(exc)})
|
||
samples = args.sample_tile or manifest.get("sampleTiles", []) or manifest.get("sampleTileCandidates", [])
|
||
if not samples:
|
||
raise ValueError("manifest 未提供 sampleTiles/sampleTileCandidates;请用 --sample-tile 明确指定")
|
||
successful_tiles, failed_tiles = sample_one(template, samples, intake / "samples")
|
||
source_manifest = {
|
||
"schemaVersion": "pbf-source-manifest/v1", "provider": provider, "dataset": dataset, "package": package,
|
||
"resources": {"style": style_info, "tileSource": tile_source, "sprite": sprite_info, "glyphs": glyph_info},
|
||
"bounds": manifest.get("bounds"), "zoom": manifest.get("zoom", {}),
|
||
"sampleTiles": successful_tiles,
|
||
"failedSampleTiles": failed_tiles, "semanticSources": manifest.get("semanticSources", []),
|
||
}
|
||
source_manifest["sourceFingerprint"] = fingerprint({k: v for k, v in source_manifest.items() if k != "sourceFingerprint"})
|
||
(intake / "source-manifest.json").write_text(json.dumps(source_manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
record["stages"]["PBF-01"] = {"status": "PASS" if successful_tiles and style_info["available"] and sprite_info.get("available") else "PASS_WITH_WARNINGS", "sourceFingerprint": source_manifest["sourceFingerprint"], "samples": successful_tiles, "failedSamples": failed_tiles}
|
||
if not successful_tiles:
|
||
raise RuntimeError("PBF-01 未取得任何真实 PBF tile,停止后续阶段")
|
||
|
||
vo_out = work / "pbf-02-style-visual-object"
|
||
if not (vo_out / "anonymous-catalog.json").exists():
|
||
spec = importlib.util.spec_from_file_location("style_vo_tool", ROOT / "tools/style-visual-object/style_vo_tool.py")
|
||
module = importlib.util.module_from_spec(spec); assert spec and spec.loader; spec.loader.exec_module(module)
|
||
catalog = module.extract(str(intake / "style.snapshot.json"), str(intake / "sprite" / "sprite") if sprite_info.get("available") else None, vo_out, f"{provider_id}-anonymous")
|
||
else:
|
||
catalog = json.loads((vo_out / "anonymous-catalog.json").read_text(encoding="utf-8"))
|
||
record["stages"]["PBF-02"] = {"status": "PASS", "visualObjects": catalog["summary"]["visual_object_count"], "unresolved": catalog["summary"]["unresolved_count"]}
|
||
|
||
if not (work / "pbf-03-actual" / "actual-anonymous-catalog.json").exists():
|
||
instance_output = work / "pbf-03-actual"
|
||
for item in successful_tiles:
|
||
z, x, y = item["tile"].split("/")
|
||
dest = instance_output / "tiles" / z / x / f"{y}.pbf"
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(item["snapshot"], dest)
|
||
command = [sys.executable, str(ROOT / "tools/visual-object-instantiation/instantiate_pbf_visual_objects.py"), "--style", str(intake / "style.snapshot.json"), "--sprite-json", str(intake / "sprite/sprite.json"), "--sprite-png", str(intake / "sprite/sprite.png"), "--pbf-root", str(instance_output / "tiles"), "--output", str(instance_output), "--provider-id", provider_id, "--dataset-id", dataset_id, "--provider-version", str(provider.get("version", "unknown"))]
|
||
if not sprite_info.get("available"):
|
||
raise RuntimeError("PBF-03 当前要求 Sprite JSON/PNG 可用")
|
||
completed = subprocess.run(command, cwd=ROOT, text=True, capture_output=True)
|
||
(instance_output / "instantiation.log").write_text(completed.stdout + completed.stderr, encoding="utf-8")
|
||
if completed.returncode:
|
||
raise RuntimeError(f"PBF-03 实例化失败,详见 {instance_output / 'instantiation.log'}")
|
||
actual = json.loads((work / "pbf-03-actual" / "actual-anonymous-catalog.json").read_text(encoding="utf-8"))
|
||
for row in actual.get("visual_objects", []):
|
||
matching_occurrences = [x for x in actual.get("occurrences", []) if x["visual_object_id"] == row["visual_object_id"]]
|
||
row["occurrence_ids"] = [x["occurrence_id"] for x in matching_occurrences]
|
||
record["stages"]["PBF-03"] = {"status": "PASS", **actual["summary"], "occurrenceCount": len(actual.get("occurrences", []))}
|
||
|
||
evidence_path = manifest.get("mapping", {}).get("executionEvidenceCatalog")
|
||
execution_catalog = None
|
||
evidence_source = None
|
||
if evidence_path:
|
||
execution_catalog, evidence_source = load_execution_catalog(
|
||
manifest_path, evidence_path, style_info["sha256"]
|
||
)
|
||
# A declared full execution catalog is the mapping universe, not merely
|
||
# a lookup sidecar for the sampled subset of Visual Objects.
|
||
mapping_actual = select_mapping_catalog(actual, execution_catalog)
|
||
mapping_mode = manifest.get("mapping", {}).get("mode", "frozen-standard-visual")
|
||
if mapping_mode == "manual-assignment":
|
||
assignment_path = (manifest_path.parent / manifest["mapping"]["assignmentFile"]).resolve()
|
||
assignment_doc = json.loads(assignment_path.read_text(encoding="utf-8"))
|
||
assignments = manual_assignment(mapping_actual, assignment_doc)
|
||
mapping_source = str(assignment_path)
|
||
else:
|
||
standard_path = (manifest_path.parent / manifest.get("mapping", {}).get("standardCatalog", "../../tools/style-visual-object/output/navsea-standard/standard-catalog.json")).resolve()
|
||
if not standard_path.exists():
|
||
standard_path = ROOT / "tools/style-visual-object/output/navsea-standard/standard-catalog.json"
|
||
standard = json.loads(standard_path.read_text(encoding="utf-8"))
|
||
assignments = standard_assignment(mapping_actual, standard)
|
||
mapping_source = str(standard_path)
|
||
occurrence_lookup: dict[str, list[str]] = {}
|
||
for occ in actual.get("occurrences", []):
|
||
occurrence_lookup.setdefault(occ["visual_object_id"], []).append(occ["occurrence_id"])
|
||
for row in assignments:
|
||
row["occurrenceIds"] = occurrence_lookup.get(row["visualObjectId"], [])
|
||
evidence_missing: list[str] = []
|
||
if execution_catalog is not None:
|
||
evidence_missing = attach_render_evidence(assignments, execution_catalog)
|
||
mapping_doc = {"schemaVersion": "navsea-standard-layer-mapping/v2" if evidence_path else "navsea-standard-layer-mapping/v1", "source": mapping_source, "assignments": assignments}
|
||
if evidence_source:
|
||
mapping_doc["renderEvidenceSource"] = evidence_source
|
||
mapping_doc["diagnostics"] = [{"code": "renderEvidence:catalog_missing_visual_object", "visualObjectId": x} for x in evidence_missing]
|
||
mapping_path = work / "pbf-04-mapping" / "provider-layer-assignment.json"
|
||
mapping_path.parent.mkdir(parents=True, exist_ok=True)
|
||
mapping_path.write_text(json.dumps(mapping_doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
unresolved = [{"visualObjectId": x["visualObjectId"], "status": x["status"], "candidateStandardLayers": x["candidateStandardLayers"]} for x in assignments if x["status"] in {"AMBIGUOUS", "UNKNOWN", "COMPONENT_ONLY"}]
|
||
unresolved += [{"visualObjectId": x, "status": "MISSING_RENDER_EVIDENCE"} for x in evidence_missing]
|
||
record["stages"]["PBF-04"] = {"status": "PASS_WITH_WARNINGS" if unresolved else "PASS", "mappingMode": mapping_mode, "assignments": len(assignments), "unresolved": len(unresolved), "missingRenderEvidence": evidence_missing, "source": mapping_source,
|
||
"catalogScope": "DECLARED_EXECUTION_CATALOG" if execution_catalog is not None else "SAMPLED_PBF",
|
||
"sampleVisualObjects": len(actual.get("visual_objects", [])),
|
||
"mappingVisualObjects": len(mapping_actual.get("visual_objects", []))}
|
||
|
||
fingerprints = {
|
||
"source": source_manifest["sourceFingerprint"], "style": style_info["sha256"],
|
||
"sprite": sprite_info.get("sha256"), "mapping": fingerprint(mapping_doc),
|
||
}
|
||
if mapping_mode != "manual-assignment":
|
||
fingerprints["standardCatalog"] = fingerprint(standard)
|
||
data = {
|
||
"provider": provider, "dataset": dataset, "package": package,
|
||
"standardCatalog": {"id": "navsea-frozen-standard", "schemaVersion": "style-visual-object-standard/v1", "fingerprint": fingerprints.get("standardCatalog")},
|
||
"resources": {
|
||
"style": {"uri": style_uri, "mediaType": "application/vnd.mapbox.style+json", "sha256": style_info["sha256"], "version": style.get("version")},
|
||
"pbfTemplate": {"uriTemplate": template, "protocol": "XYZ"},
|
||
"pbfSamples": [{"tile": x["tile"], "uri": x["uri"], "sha256": x["sha256"], "bytes": x["bytes"]} for x in successful_tiles],
|
||
"sprite": {"uri": sprite_base, "jsonSha256": sprite_info.get("sha256"), "pngSha256": sprite_info.get("png", {}).get("sha256")},
|
||
"glyphs": glyph_info,
|
||
},
|
||
"mapping": assignments, "mappingSchemaVersion": mapping_doc["schemaVersion"], "renderEvidenceSource": evidence_source, "mappingDiagnostics": mapping_doc.get("diagnostics", []),
|
||
"display": manifest.get("policy", {}).get("display", {}), "pickup": manifest.get("policy", {}).get("pickup", {}),
|
||
"planning": manifest.get("policy", {}).get("planning", {}), "danger": manifest.get("policy", {}).get("danger", {}),
|
||
"capabilities": manifest.get("policy", {}).get("capabilities", {}),
|
||
"validationRequirements": {"requireStyle": True, "requirePbfSample": True, "requireSprite": True, "minimumMappingCount": 1},
|
||
"unresolved": unresolved, "limitations": manifest.get("limitations", []),
|
||
"provenance": {"manifest": str(manifest_path), "mappingSource": mapping_source, "pipelineVersion": "1.1.0", "generatedAt": None if evidence_path else datetime.now(timezone.utc).isoformat()},
|
||
"fingerprints": fingerprints,
|
||
}
|
||
record["stages"]["PBF-05"] = {"status": "PASS", "schemaVersion": "navsea-provider-policy/v1"}
|
||
policy = build_policy(data)
|
||
policy_validation = validate_policy(policy, style)
|
||
if policy_validation["result"] == "FAIL":
|
||
raise RuntimeError(f"Provider Policy 校验 FAIL: {policy_validation['errors']}")
|
||
(work / "pbf-05-policy.json").write_text(json.dumps(policy, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
record["stages"]["PBF-05"]["validation"] = policy_validation
|
||
package_doc = build_package(data, policy, policy_validation)
|
||
validation = validate_package(package_doc, style)
|
||
package_doc["validation"] = validation
|
||
record["stages"]["PBF-06"] = {"status": validation["result"], "schemaVersion": "navsea-provider-package/v1", "validation": validation}
|
||
record["validationResult"] = validation["result"]
|
||
if not args.no_export:
|
||
export_package(export_dir, package_doc, policy, validation)
|
||
shutil.copy2(intake / "style.snapshot.json", export_dir / "resources-style.json")
|
||
shutil.copy2(intake / "sprite/sprite.json", export_dir / "resources-sprite.json")
|
||
shutil.copy2(intake / "sprite/sprite.png", export_dir / "resources-sprite.png")
|
||
shutil.copy2(mapping_path, export_dir / "mapping-layer-assignment.json")
|
||
record["stages"]["PBF-07"] = {"status": "EXPORTED", "path": str(export_dir)}
|
||
delivery_zip = create_delivery_zip(export_dir, provider_id, dataset_id, version)
|
||
record["stages"]["PBF-08"] = {"status": "PACKAGED", **delivery_zip}
|
||
else:
|
||
delivery_zip = None
|
||
record["stages"]["PBF-07"] = {"status": "SKIPPED_BY_REQUEST"}
|
||
record["stages"]["PBF-08"] = {"status": "SKIPPED_BY_REQUEST"}
|
||
report_path = work / "pipeline-report.json"
|
||
report_path.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
print(json.dumps({"providerId": provider_id, "datasetId": dataset_id, "validationResult": validation["result"], "sampleTiles": [x["tile"] for x in successful_tiles], "output": None if args.no_export else str(export_dir), "deliveryZip": None if delivery_zip is None else delivery_zip["path"], "deliveryZipSha256": None if delivery_zip is None else delivery_zip["sha256"], "report": str(report_path)}, ensure_ascii=False, indent=2))
|
||
return 0 if validation["result"] != "FAIL" else 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
raise SystemExit(main())
|
||
except Exception as error:
|
||
print(f"ERROR: {type(error).__name__}: {error}", file=sys.stderr)
|
||
raise SystemExit(2)
|