Files

254 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Validate and semantically patch coordinate-free Assembly DesignIR."""
from __future__ import annotations
import argparse
import copy
import json
from pathlib import Path
from typing import Any
import jsonschema
FORBIDDEN_PLACEMENT_KEYS = {
"transform",
"transforms",
"matrix",
"matrices",
"placement",
"placements",
"position",
"positions",
"location",
"locations",
"xyz",
"rpy",
"quaternion",
"translation",
}
def walk_forbidden(value: Any, path: str = "$") -> list[str]:
failures: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}.{key}"
if key.lower() in FORBIDDEN_PLACEMENT_KEYS:
failures.append(child_path)
failures.extend(walk_forbidden(child, child_path))
elif isinstance(value, list):
for index, child in enumerate(value):
failures.extend(walk_forbidden(child, f"{path}[{index}]"))
return failures
def component_interfaces(payload: dict[str, Any]) -> dict[str, set[str]]:
return {
component["id"]: {interface["id"] for interface in component["interfaces"]}
for component in payload["components"]
}
def validate_references(payload: dict[str, Any]) -> list[str]:
failures: list[str] = []
interfaces = component_interfaces(payload)
component_ids = set(interfaces)
if len(component_ids) != len(payload["components"]):
failures.append("component ids are not unique")
relation_ids: set[str] = set()
adjacency = {component_id: set() for component_id in component_ids}
for relation in payload["relations"]:
if relation["id"] in relation_ids:
failures.append(f"duplicate relation id: {relation['id']}")
relation_ids.add(relation["id"])
endpoints: list[tuple[str, str]] = []
for side in ("a", "b"):
component_id, interface_id = relation[side].split(".", 1)
endpoints.append((component_id, interface_id))
if component_id not in interfaces:
failures.append(
f"{relation['id']}.{side}: unknown component {component_id}"
)
elif interface_id not in interfaces[component_id]:
failures.append(
f"{relation['id']}.{side}: unknown interface "
f"{component_id}.{interface_id}"
)
if all(component_id in component_ids for component_id, _ in endpoints):
adjacency[endpoints[0][0]].add(endpoints[1][0])
adjacency[endpoints[1][0]].add(endpoints[0][0])
anchor = payload["kinematics"]["anchor_component"]
if anchor not in component_ids:
failures.append(f"unknown anchor component: {anchor}")
elif component_ids:
visited: set[str] = set()
frontier = [anchor]
while frontier:
current = frontier.pop()
if current in visited:
continue
visited.add(current)
frontier.extend(adjacency[current] - visited)
disconnected = sorted(component_ids - visited)
if disconnected:
failures.append(
"constraint graph disconnected: " + ", ".join(disconnected)
)
group_ids: set[str] = set()
for group in payload["kinematics"]["rigid_groups"]:
if group["id"] in group_ids:
failures.append(f"duplicate rigid group id: {group['id']}")
group_ids.add(group["id"])
for member in group["members"]:
if member not in component_ids:
failures.append(
f"rigid group {group['id']} has unknown member {member}"
)
joint_ids: set[str] = set()
for joint in payload["kinematics"]["joints"]:
joint_ids.add(joint["id"])
for side in ("parent_group", "child_group"):
if joint[side] not in group_ids:
failures.append(
f"joint {joint['id']} has unknown {side} {joint[side]}"
)
for ref_key in ("axis_ref", "station_ref"):
component_id, interface_id = joint[ref_key].split(".", 1)
if (
component_id not in interfaces
or interface_id not in interfaces[component_id]
):
failures.append(
f"joint {joint['id']} has unknown {ref_key} "
f"{joint[ref_key]}"
)
for transmission in payload["kinematics"]["transmissions"]:
for key in ("input_joint", "output_joint"):
if transmission[key] not in joint_ids:
failures.append(
f"transmission {transmission['id']} has unknown "
f"{key} {transmission[key]}"
)
fixed_member = transmission.get("fixed_member")
if fixed_member and fixed_member not in component_ids:
failures.append(
f"transmission {transmission['id']} has unknown fixed member "
f"{fixed_member}"
)
parameter_ids = set(payload["parameters"])
for parameter_id in payload["edit_interface"]["editable_parameters"]:
if parameter_id not in parameter_ids:
failures.append(f"unknown editable parameter: {parameter_id}")
elif not payload["parameters"][parameter_id]["editable"]:
failures.append(f"parameter is not editable: {parameter_id}")
return failures
def apply_patch(payload: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
if patch["target_model"] != payload["model_id"]:
raise ValueError("Patch target_model does not match base model")
result = copy.deepcopy(payload)
groups = {
group["id"]: group for group in result["kinematics"]["rigid_groups"]
}
for operation in patch["operations"]:
kind = operation["op"]
if kind == "add_parameter":
result["parameters"][operation["id"]] = operation["value"]
result["edit_interface"]["editable_parameters"].append(
operation["id"]
)
elif kind == "add_component":
result["components"].append(operation["value"])
elif kind == "add_relation":
result["relations"].append(operation["value"])
elif kind == "add_rigid_group_member":
groups[operation["group"]]["members"].append(
operation["component"]
)
elif kind == "add_preserved_interface":
result["edit_interface"]["preserved_interfaces"].append(
operation["value"]
)
else:
raise ValueError(f"Unsupported semantic patch operation: {kind}")
result["model_id"] = f"{payload['model_id']}_with_output_shaft"
return result
def validate(
payload: dict[str, Any], schema: dict[str, Any]
) -> dict[str, Any]:
schema_errors = sorted(
jsonschema.Draft202012Validator(schema).iter_errors(payload),
key=lambda error: list(error.absolute_path),
)
failures = [
f"{'.'.join(map(str, error.absolute_path))}: {error.message}"
for error in schema_errors
]
forbidden = walk_forbidden(payload)
failures.extend(
f"coordinate placement key is forbidden: {path}" for path in forbidden
)
if not schema_errors:
failures.extend(validate_references(payload))
return {
"status": "pass" if not failures else "fail",
"schema_version": payload.get("schema_version"),
"model_id": payload.get("model_id"),
"coordinate_free": not forbidden,
"component_count": len(payload.get("components", [])),
"relation_count": len(payload.get("relations", [])),
"joint_count": len(payload.get("kinematics", {}).get("joints", [])),
"transmission_count": len(
payload.get("kinematics", {}).get("transmissions", [])
),
"failures": failures,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("model", type=Path)
parser.add_argument("--schema", type=Path, required=True)
parser.add_argument("--patch", type=Path)
parser.add_argument("--resolved-output", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
payload = json.loads(args.model.read_text(encoding="utf-8"))
schema = json.loads(args.schema.read_text(encoding="utf-8"))
if args.patch:
patch = json.loads(args.patch.read_text(encoding="utf-8"))
payload = apply_patch(payload, patch)
if args.resolved_output:
args.resolved_output.parent.mkdir(parents=True, exist_ok=True)
args.resolved_output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
report = validate(payload, schema)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=False))
raise SystemExit(0 if report["status"] == "pass" else 1)
if __name__ == "__main__":
main()