309 lines
17 KiB
Python
309 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Build editor projects from explicit plans using this checkout's real contracts.
|
|
|
|
Natural-language interpretation belongs to the skill. This helper never infers
|
|
components, connections or physical inputs. No running HTTP service is required.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections import Counter
|
|
from copy import deepcopy
|
|
import math
|
|
from pathlib import Path
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
|
sys.path.insert(0, str(REPOSITORY_ROOT))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from simulation_skill import ( # noqa: E402
|
|
InputError, SkillCliError, _canonical_json, _ensure_distinct_new_output,
|
|
_sha256, _write_new_file, configure_standard_streams, emit_json,
|
|
load_source, nonnegative_integer, page_limit, stable_error_payload,
|
|
)
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise InputError("PROJECT_CONTRACT_INVALID", message)
|
|
|
|
|
|
def record(value, allowed, required, path):
|
|
require(isinstance(value, dict), f"{path} must be an object.")
|
|
require(not set(value) - set(allowed), f"{path} has unknown fields: {sorted(set(value) - set(allowed))}.")
|
|
require(set(required) <= set(value), f"{path} is missing fields: {sorted(set(required) - set(value))}.")
|
|
return value
|
|
|
|
|
|
def text_value(value):
|
|
return isinstance(value, str) and bool(value.strip())
|
|
|
|
|
|
def finite_number(value):
|
|
try:
|
|
return type(value) in (int, float) and math.isfinite(value)
|
|
except OverflowError:
|
|
return False
|
|
|
|
|
|
def parameter_value(value):
|
|
return isinstance(value, str) or finite_number(value)
|
|
|
|
|
|
def catalog_models():
|
|
from app.main import get_component_catalog
|
|
catalog = get_component_catalog()
|
|
require(catalog.get("schemaVersion") == 1, "Unsupported component catalog schema.")
|
|
# The actual editor excludes the experimental test library.
|
|
return [component for library in catalog["libraries"] if library["id"] != "experimental"
|
|
for component in library["components"]]
|
|
|
|
|
|
def build_project(plan):
|
|
plan = deepcopy(plan)
|
|
record(plan, {"name", "components", "connections", "simulation"},
|
|
{"name", "components", "connections", "simulation"}, "plan")
|
|
require(text_value(plan["name"]), "plan.name must be nonempty.")
|
|
require(isinstance(plan["components"], list) and plan["components"], "plan.components must be a nonempty array.")
|
|
require(isinstance(plan["connections"], list), "plan.connections must be an array.")
|
|
catalog = {model["modelType"]: model for model in catalog_models()}
|
|
nodes, defaults = [], {}
|
|
for index, item in enumerate(plan["components"]):
|
|
location = f"components[{index}]"
|
|
record(item, {"id", "modelType", "label", "parameters", "parameterUnits", "position", "rotation", "mirrored"},
|
|
{"id", "modelType"}, location)
|
|
require(text_value(item["id"]), f"{location}.id must be nonempty.")
|
|
require(isinstance(item["modelType"], str) and item["modelType"] in catalog,
|
|
f"{location}.modelType is not in the editor catalog: {item['modelType']}.")
|
|
model = catalog[item["modelType"]]
|
|
definitions = {p["name"]: p for p in model["parameters"]}
|
|
overrides = record(item.get("parameters", {}), definitions, (), f"{location}.parameters")
|
|
units = record(item.get("parameterUnits", {}), definitions, (), f"{location}.parameterUnits")
|
|
require(set(units) <= set(overrides), f"{location}: a selected unit requires an explicit parameter value.")
|
|
require(all(parameter_value(v) for v in overrides.values()), f"{location}: parameters must be finite numbers or strings.")
|
|
require(all(isinstance(v, str) for v in units.values()), f"{location}: units must be strings.")
|
|
ports = []
|
|
for port in model["ports"]:
|
|
snapshot = {key: port[key] for key in ("name", "kind", "domain", "nominalRole", "side")}
|
|
if port.get("positiveFlowDirection") is not None:
|
|
snapshot["positiveFlowDirection"] = port["positiveFlowDirection"]
|
|
ports.append(snapshot)
|
|
nodes.append({
|
|
"id": item["id"], "type": "simulationComponent",
|
|
"position": item.get("position", {"x": (index % 4) * 300, "y": (index // 4) * 220}),
|
|
"data": {
|
|
"label": item.get("label", item["id"]),
|
|
"componentType": model["type"], "modelType": model["modelType"],
|
|
"modelVersion": model["modelVersion"], "ports": ports,
|
|
"parameters": {**{key: p["default"] for key, p in definitions.items()}, **overrides},
|
|
"parameterUnits": {**{key: p["unit"] for key, p in definitions.items()}, **units},
|
|
"parameterScientificNotation": {}, "rotation": item.get("rotation", 0),
|
|
"mirrored": item.get("mirrored", False),
|
|
},
|
|
})
|
|
defaults[item["id"]] = sorted(set(definitions) - set(overrides))
|
|
edges = []
|
|
for index, item in enumerate(plan["connections"]):
|
|
location = f"connections[{index}]"
|
|
record(item, {"id", "source", "target"}, {"id", "source", "target"}, location)
|
|
for side in ("source", "target"):
|
|
record(item[side], {"component", "port"}, {"component", "port"}, f"{location}.{side}")
|
|
edges.append({"id": item["id"], "source": item["source"]["component"],
|
|
"sourceHandle": item["source"]["port"], "target": item["target"]["component"],
|
|
"targetHandle": item["target"]["port"], "data": {"isContactEdge": False}})
|
|
project = {"projectSchemaVersion": 2, "name": plan["name"], "nodes": nodes,
|
|
"edges": edges, "simulation": plan["simulation"]}
|
|
validate_editor_shape(project)
|
|
return project, defaults
|
|
|
|
|
|
def validate_editor_shape(project):
|
|
"""Preflight the stricter editor shape; actual browser acceptance is separate."""
|
|
record(project, {"projectSchemaVersion", "name", "nodes", "edges", "simulation"},
|
|
{"projectSchemaVersion", "name", "nodes", "edges", "simulation"}, "project")
|
|
require(type(project["projectSchemaVersion"]) is int and project["projectSchemaVersion"] in (1, 2),
|
|
"Expected projectSchemaVersion 1 or 2.")
|
|
require(isinstance(project["name"], str), "Project name must be a string.")
|
|
require(isinstance(project["nodes"], list) and project["nodes"], "Project nodes must be nonempty.")
|
|
require(isinstance(project["edges"], list), "Project edges must be an array.")
|
|
simulation = record(project["simulation"], {"t_start", "t_stop", "step", "max_step", "method"},
|
|
{"t_start", "t_stop", "step", "max_step", "method"}, "simulation")
|
|
require(isinstance(simulation["method"], str) and simulation["method"] in ("RK45", "BDF"),
|
|
"Only RK45 and BDF are executable.")
|
|
require(all(parameter_value(simulation[k]) for k in ("t_start", "t_stop", "step", "max_step")),
|
|
"Simulation inputs must be finite numbers or expressions.")
|
|
node_ids, edge_ids = set(), set()
|
|
for node in project["nodes"]:
|
|
require(isinstance(node, dict), "Each node must be an object.")
|
|
ident = node.get("id")
|
|
require(text_value(ident) and ident not in node_ids, "Node IDs must be nonempty and unique.")
|
|
node_ids.add(ident)
|
|
require(node.get("type") == "simulationComponent", f"{ident}: invalid editor node type.")
|
|
position = node.get("position")
|
|
require(isinstance(position, dict) and all(finite_number(position.get(k)) for k in ("x", "y")),
|
|
f"{ident}: position requires finite x/y.")
|
|
data = node.get("data")
|
|
require(isinstance(data, dict), f"{ident}: data must be an object.")
|
|
require(isinstance(data.get("label"), str) and all(text_value(data.get(k)) for k in ("componentType", "modelType")),
|
|
f"{ident}: label and component/model type are required.")
|
|
require("modelVersion" not in data or isinstance(data["modelVersion"], str), f"{ident}: invalid modelVersion.")
|
|
require(type(data.get("rotation")) in (int, float) and data["rotation"] in (0, 90, 180, 270)
|
|
and type(data.get("mirrored")) is bool, f"{ident}: invalid rotation/mirrored.")
|
|
parameters = data.get("parameters")
|
|
require(isinstance(parameters, dict) and all(parameter_value(v) for v in parameters.values()),
|
|
f"{ident}: parameters must be finite numbers or strings.")
|
|
units = data.get("parameterUnits", {})
|
|
require(isinstance(units, dict) and all(isinstance(v, str) for v in units.values()), f"{ident}: invalid parameterUnits.")
|
|
require(isinstance(data.get("parameterScientificNotation", {}), dict), f"{ident}: invalid scientific notation metadata.")
|
|
ports = data.get("ports")
|
|
require(isinstance(ports, list), f"{ident}: ports must be objects, not strings.")
|
|
names = set()
|
|
for port in ports:
|
|
require(isinstance(port, dict), f"{ident}: invalid port snapshot.")
|
|
name = port.get("name")
|
|
require(text_value(name) and name not in names, f"{ident}: port names must be nonempty and unique.")
|
|
names.add(name)
|
|
require(port.get("kind") in ("physical", "signal") and text_value(port.get("domain"))
|
|
and port.get("nominalRole") in ("inlet", "outlet", "bidirectional", "input", "output")
|
|
and port.get("side") in ("left", "right"), f"{ident}.{name}: invalid port snapshot.")
|
|
require("positiveFlowDirection" not in port or port["positiveFlowDirection"] == "intoComponent",
|
|
f"{ident}.{name}: omit null positiveFlowDirection for browser import.")
|
|
for edge in project["edges"]:
|
|
require(isinstance(edge, dict), "Each edge must be an object.")
|
|
ident = edge.get("id")
|
|
require(text_value(ident) and ident not in edge_ids, "Edge IDs must be nonempty and unique.")
|
|
edge_ids.add(ident)
|
|
require(all(text_value(edge.get(k)) for k in ("source", "target", "sourceHandle", "targetHandle")),
|
|
f"{ident}: explicit endpoints and handles are required.")
|
|
require(edge["source"] in node_ids and edge["target"] in node_ids, f"{ident}: missing endpoint component.")
|
|
data = edge.get("data")
|
|
require(isinstance(data, dict) and type(data.get("isContactEdge")) is bool,
|
|
f"{ident}: data.isContactEdge must be boolean.")
|
|
if "routePoints" in data:
|
|
require(isinstance(data["routePoints"], list) and all(
|
|
isinstance(p, dict) and all(finite_number(p.get(k)) for k in ("x", "y"))
|
|
for p in data["routePoints"]), f"{ident}: invalid routePoints.")
|
|
|
|
|
|
def validate_project(project):
|
|
from app.main import (ReactFlowProjectPayload, build_reactflow_system_xml,
|
|
compile_reactflow_network, validate_reactflow_component_contract)
|
|
from app.project_parameters import prepare_project, version_warning
|
|
from app.simulation.registry import get_component_model_spec
|
|
validate_editor_shape(project)
|
|
available = {model["modelType"] for model in catalog_models()}
|
|
require(all(node["data"]["modelType"] in available for node in project["nodes"]),
|
|
"Project contains a model that is not available in the editor catalog.")
|
|
try:
|
|
normalized, notices = prepare_project(ReactFlowProjectPayload.model_validate(project))
|
|
xml = build_reactflow_system_xml(normalized)
|
|
connected = Counter(endpoint for edge in normalized.edges for endpoint in (
|
|
(edge.source, edge.sourceHandle), (edge.target, edge.targetHandle)))
|
|
# Store the full registered snapshot, but check only currently active ports.
|
|
for node in normalized.nodes:
|
|
spec = get_component_model_spec(node.data.modelType)
|
|
parameters = validate_reactflow_component_contract(node, spec)
|
|
for port in spec.active_ports(parameters):
|
|
count = connected[(node.id, port.name)]
|
|
require(count == 1, f"{node.id}.{port.name}: expected one connection, found {count}.")
|
|
compiled = compile_reactflow_network(normalized).as_interface_dict()
|
|
except (ValueError, KeyError) as exc:
|
|
raise InputError("PROJECT_EXECUTION_INVALID", str(exc)) from exc
|
|
warnings = [version_warning(notices)] if notices else []
|
|
report = {
|
|
"ok": True, "projectSchemaVersion": project["projectSchemaVersion"],
|
|
"componentCount": len(project["nodes"]), "connectionCount": len(project["edges"]),
|
|
"warnings": warnings,
|
|
"checks": {"editorShape": "passed", "activePorts": "passed", "xmlV3": "passed",
|
|
"networkCompile": "passed", "browserImport": "notRun", "simulation": "notRun"},
|
|
"compiledComponentCount": len(compiled.get("components", [])),
|
|
}
|
|
return report, xml
|
|
|
|
|
|
def semantic_signature(xml):
|
|
"""Compare SI execution inputs, ignoring layout and reversible edge direction."""
|
|
root = ET.fromstring(xml)
|
|
simulation = root.find("Simulation")
|
|
return {
|
|
"simulation": {k: v if k == "method" else float(v) for k, v in simulation.attrib.items()},
|
|
"components": {c.attrib["id"]: {
|
|
"type": c.attrib["type"], "modelVersion": c.attrib["modelVersion"],
|
|
"parameters": {p.attrib["name"]: float(p.attrib["value"]) for p in c.findall("Parameter")},
|
|
} for c in root.findall("Components/Component")},
|
|
"connections": {c.attrib["id"]: sorted((e.attrib["component"], e.attrib["port"]) for e in c)
|
|
for c in root.findall("Connections/Connection")},
|
|
}
|
|
|
|
|
|
def main(argv=None):
|
|
configure_standard_streams()
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
catalog = sub.add_parser("catalog", help="Read current local catalog; filter before requesting full model details.")
|
|
catalog.add_argument("--query", default="")
|
|
catalog.add_argument("--model", action="append", default=[])
|
|
catalog.add_argument("--offset", type=nonnegative_integer, default=0)
|
|
catalog.add_argument("--limit", type=page_limit, default=20)
|
|
build = sub.add_parser("build", help="Build and validate JSON v2 from an explicit plan.")
|
|
build.add_argument("input")
|
|
build.add_argument("--output", required=True)
|
|
check = sub.add_parser("check", help="Check editor shape, active connections, XML and network compilation.")
|
|
check.add_argument("input")
|
|
check.add_argument("--xml-output")
|
|
compare = sub.add_parser("compare", help="Check two projects and compare their normalized execution inputs.")
|
|
compare.add_argument("input")
|
|
compare.add_argument("roundtrip")
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
if args.command == "catalog":
|
|
models = catalog_models()
|
|
available = {m["modelType"] for m in models}
|
|
require(set(args.model) <= available, f"Unknown models: {sorted(set(args.model) - available)}.")
|
|
models = [m for m in models if (not args.model or m["modelType"] in args.model)
|
|
and args.query.casefold() in (m["modelType"] + " " + m["label"]).casefold()]
|
|
page = models[args.offset:args.offset + args.limit]
|
|
if not args.model:
|
|
page = [{k: m[k] for k in ("modelType", "label", "modelVersion")} for m in page]
|
|
emit_json({"ok": True, "command": "catalog", "source": "localCheckout", "models": page,
|
|
"total": len(models), "nextOffset": args.offset + len(page),
|
|
"hasMore": args.offset + len(page) < len(models)})
|
|
return 0
|
|
source = load_source(args.input, "json")
|
|
if args.command == "build":
|
|
output = _ensure_distinct_new_output(source.path, args.output)
|
|
project, defaults = build_project(source.parsed)
|
|
report, _ = validate_project(project)
|
|
data = _canonical_json(project)
|
|
_write_new_file(output, data)
|
|
report.update(output=str(output), sha256=_sha256(data), defaultsUsed=defaults)
|
|
else:
|
|
report, xml = validate_project(source.parsed)
|
|
if args.command == "check" and args.xml_output:
|
|
output = _ensure_distinct_new_output(source.path, args.xml_output)
|
|
_write_new_file(output, xml)
|
|
report["xmlOutput"] = str(output)
|
|
if args.command == "compare":
|
|
roundtrip = load_source(args.roundtrip, "json")
|
|
other_report, other_xml = validate_project(roundtrip.parsed)
|
|
before, after = semantic_signature(xml), semantic_signature(other_xml)
|
|
changed = [key for key in before if before[key] != after[key]]
|
|
require(not changed, f"Round-trip changed execution inputs: {', '.join(changed)}.")
|
|
report.update(executionEquivalent=True, roundtripSha256=roundtrip.sha256,
|
|
roundtripWarnings=other_report["warnings"])
|
|
report.update(command=args.command, sourceSha256=source.sha256)
|
|
emit_json(report)
|
|
return 0
|
|
except SkillCliError as exc:
|
|
emit_json(stable_error_payload(exc), stream=sys.stderr)
|
|
return exc.exit_code
|
|
except (ValueError, KeyError) as exc:
|
|
emit_json(stable_error_payload(InputError("PROJECT_CONTRACT_INVALID", str(exc))), stream=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|