Files
SystemSimulationApp/skills/system-simulation/scripts/simulation_skill.py
T
2026-09-18 01:40:58 +08:00

1942 lines
68 KiB
Python

#!/usr/bin/env python3
"""Deterministic file and simulation helper for the system-simulation skill.
The script intentionally talks to the existing FastAPI service instead of
importing solver internals. That keeps the skill boundary small and makes the
backend the authority for component contracts, XML validation and simulation.
"""
from __future__ import annotations
import argparse
import codecs
import difflib
import hashlib
import html
import io
import json
import math
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
MAX_INPUT_BYTES = 5 * 1024 * 1024
DEFAULT_BASE_URL = "http://127.0.0.1:8000"
DEFAULT_TIMEOUT_SECONDS = 30.0
MIN_TIMEOUT_SECONDS = 10.0
ALLOWED_BACKEND_HOSTS = {"127.0.0.1", "localhost", "::1"}
SUPPORTED_FORMATS = {"json", "xml"}
SIMULATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
XML_FORBIDDEN_DECLARATION = re.compile(br"<!\s*(?:DOCTYPE|ENTITY)\b", re.IGNORECASE)
CHART_COLORS = (
"#2563eb",
"#dc2626",
"#16a34a",
"#9333ea",
"#ea580c",
"#0891b2",
"#be123c",
"#4f46e5",
)
class SkillCliError(Exception):
"""Expected CLI failure with a stable machine-readable code."""
def __init__(
self,
code: str,
message: str,
*,
exit_code: int,
details: object | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.exit_code = exit_code
self.details = details
class InputError(SkillCliError):
def __init__(self, code: str, message: str, details: object | None = None) -> None:
super().__init__(code, message, exit_code=2, details=details)
class BackendError(SkillCliError):
def __init__(self, code: str, message: str, details: object | None = None) -> None:
super().__init__(code, message, exit_code=3, details=details)
class SimulationError(SkillCliError):
def __init__(self, code: str, message: str, details: object | None = None) -> None:
super().__init__(code, message, exit_code=4, details=details)
class ArtifactError(SkillCliError):
def __init__(self, code: str, message: str, details: object | None = None) -> None:
super().__init__(code, message, exit_code=5, details=details)
@dataclass(frozen=True)
class SourceFile:
path: Path
raw: bytes
text: str
encoding: str
had_bom: bool
sha256: str
format: str
parsed: object
def emit_json(payload: object, *, stream: io.TextIOBase = sys.stdout) -> None:
serialized = json.dumps(payload, ensure_ascii=False, allow_nan=False) + "\n"
try:
stream.write(serialized)
stream.flush()
except UnicodeEncodeError:
binary_stream = getattr(stream, "buffer", None)
if binary_stream is None:
raise
binary_stream.write(serialized.encode("utf-8"))
binary_stream.flush()
def configure_standard_streams() -> None:
"""Make structured JSON reliable on Windows terminals using legacy codepages."""
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="backslashreplace")
def stable_error_payload(error: SkillCliError) -> dict[str, object]:
payload: dict[str, object] = {
"ok": False,
"error": {
"code": error.code,
"message": error.message,
},
}
if error.details is not None:
payload["error"]["details"] = error.details # type: ignore[index]
return payload
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _finite_json_constant(value: str) -> None:
raise ValueError(f"JSON contains non-finite numeric constant {value}.")
def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
duplicates: list[str] = []
for key, value in pairs:
if key in result:
duplicates.append(key)
result[key] = value
if duplicates:
names = ", ".join(sorted(set(duplicates)))
raise ValueError(f"JSON contains duplicate object keys: {names}.")
return result
def parse_json_text(text: str) -> object:
try:
return json.loads(
text,
parse_constant=_finite_json_constant,
object_pairs_hook=_unique_json_object,
)
except (json.JSONDecodeError, ValueError) as exc:
raise InputError("JSON_SYNTAX_INVALID", str(exc)) from exc
def _decode_source(raw: bytes) -> tuple[str, str, bool]:
if raw.startswith(codecs.BOM_UTF8):
encoding, had_bom = "utf-8-sig", True
elif raw.startswith(codecs.BOM_UTF16_LE):
encoding, had_bom = "utf-16-le", True
elif raw.startswith(codecs.BOM_UTF16_BE):
encoding, had_bom = "utf-16-be", True
else:
encoding, had_bom = "utf-8", False
try:
text = raw.decode(encoding)
except UnicodeDecodeError as exc:
raise InputError(
"TEXT_ENCODING_UNSUPPORTED",
"Input must be UTF-8, UTF-8 with BOM, or BOM-marked UTF-16.",
{"start": exc.start, "end": exc.end},
) from exc
if text.startswith("\ufeff"):
text = text[1:]
return text, encoding, had_bom
def _content_format(text: str) -> str | None:
stripped = text.lstrip()
if stripped.startswith("{"):
return "json"
if stripped.startswith("<"):
return "xml"
return None
def _suffix_format(path: Path) -> str | None:
suffix = path.suffix.casefold()
if suffix == ".json":
return "json"
if suffix == ".xml":
return "xml"
return None
def _parse_xml(raw: bytes) -> ET.Element:
# Removing NUL bytes also exposes ASCII declarations in BOM-marked UTF-16
# input without trying to interpret arbitrary encodings here.
if XML_FORBIDDEN_DECLARATION.search(raw.replace(b"\x00", b"")):
raise InputError(
"XML_EXTERNAL_DECLARATION_FORBIDDEN",
"DOCTYPE and ENTITY declarations are not accepted.",
)
try:
parser = ET.XMLParser(
target=ET.TreeBuilder(insert_comments=True, insert_pis=True)
)
return ET.fromstring(raw, parser=parser)
except ET.ParseError as exc:
details: dict[str, object] = {}
if getattr(exc, "position", None):
details["line"], details["column"] = exc.position
raise InputError("XML_SYNTAX_INVALID", str(exc), details or None) from exc
def load_source(path_text: str, requested_format: str = "auto") -> SourceFile:
path = Path(path_text).expanduser()
try:
resolved = path.resolve(strict=True)
except (FileNotFoundError, OSError) as exc:
raise InputError("INPUT_FILE_NOT_FOUND", f"Input file was not found: {path}") from exc
if not resolved.is_file():
raise InputError("INPUT_NOT_A_FILE", f"Input path is not a file: {resolved}")
try:
size = resolved.stat().st_size
except OSError as exc:
raise InputError("INPUT_FILE_UNREADABLE", str(exc)) from exc
if size > MAX_INPUT_BYTES:
raise InputError(
"INPUT_FILE_TOO_LARGE",
f"Input exceeds the {MAX_INPUT_BYTES}-byte safety limit.",
{"sizeBytes": size, "maximumBytes": MAX_INPUT_BYTES},
)
try:
raw = resolved.read_bytes()
except OSError as exc:
raise InputError("INPUT_FILE_UNREADABLE", str(exc)) from exc
if not raw.strip():
raise InputError("INPUT_FILE_EMPTY", "Input file is empty.")
text, encoding, had_bom = _decode_source(raw)
detected = _content_format(text)
if detected is None:
raise InputError(
"INPUT_FORMAT_UNRECOGNIZED",
"Input content is neither a JSON object nor an XML document.",
)
if requested_format != "auto" and requested_format != detected:
raise InputError(
"INPUT_FORMAT_MISMATCH",
f"Requested {requested_format}, but the content looks like {detected}.",
)
if detected == "json":
parsed = parse_json_text(text)
if not isinstance(parsed, dict):
raise InputError("JSON_ROOT_NOT_OBJECT", "Project JSON root must be an object.")
else:
parsed = _parse_xml(raw)
return SourceFile(
path=resolved,
raw=raw,
text=text,
encoding=encoding,
had_bom=had_bom,
sha256=_sha256(raw),
format=detected,
parsed=parsed,
)
def validate_base_url(value: str) -> str:
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise InputError("BACKEND_URL_INVALID", "Backend URL must be an HTTP(S) URL.")
if parsed.username or parsed.password:
raise InputError("BACKEND_URL_CREDENTIALS_FORBIDDEN", "Credentials are not allowed in the backend URL.")
if parsed.query or parsed.fragment or parsed.params or parsed.path not in {"", "/"}:
raise InputError(
"BACKEND_URL_PATH_FORBIDDEN",
"Backend URL must contain only scheme, loopback host and optional port.",
)
if parsed.hostname.casefold() not in ALLOWED_BACKEND_HOSTS:
raise InputError(
"REMOTE_BACKEND_FORBIDDEN",
"This base version only connects to a loopback backend.",
{"host": parsed.hostname},
)
return value.rstrip("/")
def _backend_url(base_url: str, path: str) -> str:
return f"{base_url}{path}"
def _decode_backend_error(data: bytes, status: int) -> object:
try:
parsed = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
text = data.decode("utf-8", errors="replace").strip()
return {"status": status, "message": text[:4000]}
return {"status": status, "response": parsed}
def http_request(
base_url: str,
path: str,
*,
method: str = "GET",
body: bytes | None = None,
content_type: str | None = None,
timeout: float,
headers: Mapping[str, str] | None = None,
) -> tuple[bytes, Mapping[str, str]]:
request_headers = {"Accept": "application/json"}
if content_type:
request_headers["Content-Type"] = content_type
if headers:
request_headers.update(headers)
request = urllib.request.Request(
_backend_url(base_url, path),
data=body,
headers=request_headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read(), dict(response.headers.items())
except urllib.error.HTTPError as exc:
data = exc.read()
raise BackendError(
"BACKEND_HTTP_ERROR",
f"Backend returned HTTP {exc.code}.",
_decode_backend_error(data, exc.code),
) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise BackendError("BACKEND_UNAVAILABLE", str(exc)) from exc
def http_json(
base_url: str,
path: str,
*,
method: str = "GET",
payload: object | None = None,
timeout: float,
) -> object:
body = None
content_type = None
if payload is not None:
try:
body = json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
except (TypeError, ValueError) as exc:
raise InputError("REQUEST_JSON_INVALID", str(exc)) from exc
content_type = "application/json"
data, _ = http_request(
base_url,
path,
method=method,
body=body,
content_type=content_type,
timeout=timeout,
)
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BackendError(
"BACKEND_RESPONSE_INVALID",
"Backend response was not valid UTF-8 JSON.",
) from exc
def _canonical_json(value: object) -> bytes:
try:
return (
json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
indent=2,
sort_keys=False,
)
+ "\n"
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise InputError("JSON_VALUE_UNSUPPORTED", str(exc)) from exc
def _canonical_xml(source: SourceFile) -> bytes:
root = _parse_xml(source.raw)
ET.indent(root, space=" ")
rendered = ET.tostring(
root,
encoding="utf-8",
xml_declaration=True,
short_empty_elements=True,
)
return rendered.rstrip(b"\r\n") + b"\n"
def canonical_bytes(source: SourceFile) -> bytes:
if source.format == "json":
return _canonical_json(source.parsed)
return _canonical_xml(source)
def _line_ending_kind(text: str) -> str:
crlf = text.count("\r\n")
without_crlf = text.replace("\r\n", "")
lf = without_crlf.count("\n")
cr = without_crlf.count("\r")
kinds = sum(count > 0 for count in (crlf, lf, cr))
if kinds > 1:
return "mixed"
if crlf:
return "crlf"
if cr:
return "cr"
return "lf"
def text_normalization_report(source: SourceFile, canonical: bytes) -> dict[str, object]:
issues: list[dict[str, object]] = []
if source.had_bom:
issues.append(
{
"code": "TEXT_BOM_PRESENT",
"message": "The normalized file uses UTF-8 without a BOM.",
"repairable": True,
}
)
if source.encoding not in {"utf-8", "utf-8-sig"}:
issues.append(
{
"code": "TEXT_ENCODING_NOT_UTF8",
"message": f"The source encoding is {source.encoding}; normalized output uses UTF-8.",
"repairable": True,
}
)
line_endings = _line_ending_kind(source.text)
if line_endings != "lf":
issues.append(
{
"code": "TEXT_LINE_ENDINGS_NOT_LF",
"message": f"The source uses {line_endings} line endings; normalized output uses LF.",
"repairable": True,
}
)
if not source.text.endswith(("\n", "\r")):
issues.append(
{
"code": "TEXT_FINAL_NEWLINE_MISSING",
"message": "The normalized file ends with one LF newline.",
"repairable": True,
}
)
if source.raw != canonical and not issues:
issues.append(
{
"code": "TEXT_LAYOUT_NOT_CANONICAL",
"message": "Whitespace or serialization layout differs from the canonical form.",
"repairable": True,
}
)
return {
"needed": source.raw != canonical,
"issues": issues,
"canonicalSha256": _sha256(canonical),
"canonicalSizeBytes": len(canonical),
}
def _json_request_bytes(value: object) -> bytes:
try:
return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8")
except (TypeError, ValueError) as exc:
raise InputError("JSON_VALUE_UNSUPPORTED", str(exc)) from exc
def _expect_mapping(value: object, *, code: str, message: str) -> dict[str, object]:
if not isinstance(value, dict):
raise BackendError(code, message, {"receivedType": type(value).__name__})
return value
def _xml_inspection(
source: SourceFile,
*,
base_url: str,
timeout: float,
) -> tuple[dict[str, object], dict[str, object]]:
parsed_raw, _ = http_request(
base_url,
"/api/system-xml/parse",
method="POST",
body=source.raw,
content_type="application/xml",
timeout=timeout,
)
compiled_raw, _ = http_request(
base_url,
"/api/system-xml/compile-model",
method="POST",
body=source.raw,
content_type="application/xml",
timeout=timeout,
)
try:
parsed = _expect_mapping(
json.loads(parsed_raw.decode("utf-8")),
code="BACKEND_PARSE_RESPONSE_INVALID",
message="XML parse response must be an object.",
)
compiled = _expect_mapping(
json.loads(compiled_raw.decode("utf-8")),
code="BACKEND_COMPILE_RESPONSE_INVALID",
message="XML compile response must be an object.",
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BackendError(
"BACKEND_RESPONSE_INVALID",
"XML inspection response was not valid JSON.",
) from exc
model = _expect_mapping(
parsed.get("model"),
code="BACKEND_MODEL_MISSING",
message="XML parse response did not contain a model object.",
)
return model, compiled
def _json_inspection(
source: SourceFile,
*,
base_url: str,
timeout: float,
) -> tuple[dict[str, object], dict[str, object]]:
project = _expect_mapping(
source.parsed,
code="JSON_ROOT_NOT_OBJECT",
message="Project JSON root must be an object.",
)
raw, _ = http_request(
base_url,
"/api/reactflow/compile-model",
method="POST",
body=_json_request_bytes(project),
content_type="application/json",
timeout=timeout,
)
try:
compiled = _expect_mapping(
json.loads(raw.decode("utf-8")),
code="BACKEND_COMPILE_RESPONSE_INVALID",
message="Project compile response must be an object.",
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise BackendError(
"BACKEND_RESPONSE_INVALID",
"Project compile response was not valid JSON.",
) from exc
return project, compiled
def _result_variables(compiled: Mapping[str, object]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
components = compiled.get("components")
if not isinstance(components, list):
return result
for component in components:
if not isinstance(component, dict):
continue
variables = component.get("resultVariables")
if not isinstance(variables, list):
continue
for variable in variables:
if isinstance(variable, dict):
result.append(dict(variable))
return result
def _collection_size(value: object) -> int:
return len(value) if isinstance(value, (dict, list)) else 0
def _compiled_component_index(
compiled: Mapping[str, object],
) -> dict[str, Mapping[str, object]]:
components = compiled.get("components")
if not isinstance(components, list):
return {}
return {
str(component.get("id")): component
for component in components
if isinstance(component, dict) and component.get("id") is not None
}
def _compact_compiled_components(
compiled: Mapping[str, object],
) -> list[dict[str, object]]:
compact: list[dict[str, object]] = []
for component in _compiled_component_index(compiled).values():
variables = component.get("resultVariables")
parameters = component.get("parameters")
ports = component.get("ports")
compact.append(
{
"id": component.get("id"),
"type": component.get("type"),
"label": component.get("label"),
"modelVersion": component.get("modelVersion"),
"parameterCount": _collection_size(parameters),
"portCount": _collection_size(ports),
"resultVariableCount": _collection_size(variables),
"compiledForSimulation": True,
}
)
return compact
def _compact_components(
model: Mapping[str, object],
compiled: Mapping[str, object],
*,
source_format: str,
) -> list[dict[str, object]]:
"""Merge source components with compiler metadata without dropping resources.
Medium/property nodes intentionally do not appear in the executable network,
but they are still part of the user's project and must remain visible in an
inspection summary.
"""
compiled_by_id = _compiled_component_index(compiled)
raw_components = model.get("nodes" if source_format == "json" else "components")
if not isinstance(raw_components, list):
return _compact_compiled_components(compiled)
compact: list[dict[str, object]] = []
for raw_component in raw_components:
if not isinstance(raw_component, dict):
continue
if source_format == "json":
data = raw_component.get("data")
source_data = data if isinstance(data, dict) else {}
component_id = raw_component.get("id")
component_type = (
source_data.get("componentType")
or source_data.get("modelType")
or raw_component.get("type")
)
component_label = source_data.get("label") or raw_component.get("label")
model_version = source_data.get("modelVersion") or raw_component.get("modelVersion")
source_parameters = source_data.get("parameters")
source_ports = source_data.get("ports")
else:
source_data = raw_component
component_id = raw_component.get("id")
component_type = raw_component.get("model_type") or raw_component.get("type")
component_label = raw_component.get("label")
model_version = raw_component.get("model_version") or raw_component.get("modelVersion")
source_parameters = raw_component.get("parameters")
source_ports = raw_component.get("ports")
compiled_component = compiled_by_id.get(str(component_id))
compiled_parameters = (
compiled_component.get("parameters") if compiled_component is not None else None
)
compiled_ports = (
compiled_component.get("ports") if compiled_component is not None else None
)
variables = (
compiled_component.get("resultVariables")
if compiled_component is not None
else None
)
compact.append(
{
"id": component_id,
"type": component_type
or (compiled_component.get("type") if compiled_component is not None else None),
"label": component_label
or (compiled_component.get("label") if compiled_component is not None else None),
"modelVersion": model_version
or (
compiled_component.get("modelVersion")
if compiled_component is not None
else None
),
"parameterCount": _collection_size(source_parameters)
or _collection_size(compiled_parameters),
"portCount": _collection_size(source_ports)
or _collection_size(compiled_ports),
"resultVariableCount": _collection_size(variables),
"compiledForSimulation": compiled_component is not None,
}
)
return compact
def _page_metadata(total: int, offset: int, limit: int) -> dict[str, object]:
returned = max(0, min(limit, total - offset))
has_more = offset + returned < total
return {
"offset": offset,
"limit": limit,
"total": total,
"hasMore": has_more,
"nextOffset": offset + returned if has_more else None,
}
def _component_type_counts(components: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
counts: dict[str, int] = {}
for component in components:
model_type = str(component.get("type") or "unknown")
counts[model_type] = counts.get(model_type, 0) + 1
return [
{"type": model_type, "count": count}
for model_type, count in sorted(counts.items())
]
def _matches_variable_query(variable: Mapping[str, object], query: str | None) -> bool:
if not query:
return True
needle = query.casefold()
searchable = (
variable.get("key"),
variable.get("componentId"),
variable.get("componentType"),
variable.get("portName"),
variable.get("name"),
variable.get("label"),
variable.get("quantity"),
variable.get("unit"),
)
return any(needle in str(value).casefold() for value in searchable if value is not None)
def _variable_page(
variables: Sequence[dict[str, object]],
*,
query: str | None,
offset: int,
limit: int,
) -> tuple[list[dict[str, object]], dict[str, object]]:
filtered = [variable for variable in variables if _matches_variable_query(variable, query)]
items = filtered[offset : offset + limit]
return items, {
"query": query,
**_page_metadata(len(filtered), offset, limit),
}
def _source_component_index(
model: Mapping[str, object],
*,
source_format: str,
) -> dict[str, Mapping[str, object]]:
raw_components = model.get("nodes" if source_format == "json" else "components")
if not isinstance(raw_components, list):
return {}
return {
str(component.get("id")): component
for component in raw_components
if isinstance(component, dict) and component.get("id") is not None
}
def _component_details(
model: Mapping[str, object],
compiled: Mapping[str, object],
component_ids: Sequence[str],
*,
source_format: str,
) -> tuple[list[dict[str, object]], list[str]]:
if not component_ids:
return [], []
wanted = set(component_ids)
source_by_id = _source_component_index(model, source_format=source_format)
compiled_by_id = _compiled_component_index(compiled)
details: list[dict[str, object]] = []
found: set[str] = set()
for component_id in component_ids:
source_component = source_by_id.get(component_id)
compiled_component = compiled_by_id.get(component_id)
if source_component is None and compiled_component is None:
continue
found.add(component_id)
details.append(
{
"id": component_id,
"compiledForSimulation": compiled_component is not None,
"source": dict(source_component) if source_component is not None else None,
"compiled": dict(compiled_component) if compiled_component is not None else None,
}
)
return details, sorted(wanted - found)
def inspect_source(
source: SourceFile,
*,
base_url: str,
timeout: float,
component_ids: Sequence[str] = (),
variable_query: str | None = None,
variable_offset: int = 0,
variable_limit: int = 20,
component_offset: int = 0,
component_limit: int = 50,
connection_offset: int = 0,
connection_limit: int = 25,
) -> dict[str, object]:
canonical = canonical_bytes(source)
if source.format == "json":
model, compiled = _json_inspection(source, base_url=base_url, timeout=timeout)
nodes = model.get("nodes") if isinstance(model.get("nodes"), list) else []
edges = model.get("edges") if isinstance(model.get("edges"), list) else []
simulation = model.get("simulation")
version: object = model.get("projectSchemaVersion")
name: object = model.get("name", "untitled")
component_count = len(nodes)
connection_count = len(edges)
else:
model, compiled = _xml_inspection(source, base_url=base_url, timeout=timeout)
components = model.get("components") if isinstance(model.get("components"), list) else []
connections = model.get("connections") if isinstance(model.get("connections"), list) else []
simulation = model.get("simulation")
root = source.parsed
version = root.get("schemaVersion") if isinstance(root, ET.Element) else None
name = model.get("name", "untitled")
component_count = len(components)
connection_count = len(connections)
extension_format = _suffix_format(source.path)
source_issues: list[dict[str, object]] = []
if extension_format is not None and extension_format != source.format:
source_issues.append(
{
"code": "FILE_EXTENSION_MISMATCH",
"message": f"File extension suggests {extension_format}, but content is {source.format}.",
"repairable": False,
}
)
backend_validation = compiled.get("validation")
if isinstance(backend_validation, dict):
if backend_validation.get("valid") is False:
raise InputError("MODEL_VALIDATION_FAILED", "Backend model validation failed.", backend_validation)
backend_issues = backend_validation.get("issues")
if isinstance(backend_issues, list):
source_issues.extend(
dict(issue) for issue in backend_issues if isinstance(issue, dict)
)
backend_warnings = compiled.get("warnings")
if isinstance(backend_warnings, list):
source_issues.extend(
{**warning, "severity": "warning"}
for warning in backend_warnings if isinstance(warning, dict)
)
all_variables = _result_variables(compiled)
result_variables, variable_page = _variable_page(
all_variables,
query=variable_query,
offset=variable_offset,
limit=variable_limit,
)
component_details, missing_component_details = _component_details(
model,
compiled,
component_ids,
source_format=source.format,
)
compact_components = _compact_components(
model,
compiled,
source_format=source.format,
)
raw_connections = compiled.get("connections")
connections = (
[dict(connection) for connection in raw_connections if isinstance(connection, dict)]
if isinstance(raw_connections, list)
else []
)
system_payload: dict[str, object] = {
"name": name,
"formatVersion": version,
"simulation": simulation,
"componentCount": component_count,
"connectionCount": connection_count,
"componentTypes": _component_type_counts(compact_components),
"components": compact_components[component_offset : component_offset + component_limit],
"componentPage": _page_metadata(
len(compact_components), component_offset, component_limit
),
"connections": connections[connection_offset : connection_offset + connection_limit],
"connectionPage": _page_metadata(
len(connections), connection_offset, connection_limit
),
"unconnectedPorts": compiled.get("unconnectedPorts", []),
"resultVariables": result_variables,
"resultVariablePage": variable_page,
}
if component_ids:
system_payload["componentDetails"] = component_details
system_payload["missingComponentDetails"] = missing_component_details
return {
"ok": True,
"command": "inspect",
"source": {
"path": str(source.path),
"format": source.format,
"sizeBytes": len(source.raw),
"sha256": source.sha256,
"encoding": source.encoding,
"hadBom": source.had_bom,
},
"validation": {
"valid": True,
"issues": source_issues,
},
"textNormalization": text_normalization_report(source, canonical),
"system": system_payload,
}
def command_inspect(args: argparse.Namespace) -> int:
source = load_source(args.input, args.format)
emit_json(
inspect_source(
source,
base_url=args.base_url,
timeout=args.timeout,
component_ids=args.component,
variable_query=args.variable_query,
variable_offset=args.variable_offset,
variable_limit=args.variable_limit,
component_offset=args.component_offset,
component_limit=args.component_limit,
connection_offset=args.connection_offset,
connection_limit=args.connection_limit,
)
)
return 0
def _semantic_model(
source: SourceFile,
*,
base_url: str,
timeout: float,
) -> object:
if source.format == "json":
return source.parsed
model, _ = _xml_inspection(source, base_url=base_url, timeout=timeout)
return model
def _normalized_preview(source: SourceFile, canonical: bytes) -> dict[str, object]:
before = source.text.splitlines(keepends=True)
after = canonical.decode("utf-8").splitlines(keepends=True)
lines = list(
difflib.unified_diff(
before,
after,
fromfile=str(source.path),
tofile=f"{source.path.name}.normalized",
n=3,
)
)
truncated = len(lines) > 200
diff_text = "".join(lines[:200])
if len(diff_text) > 20000:
diff_text = diff_text[:20000]
truncated = True
return {
"sourceSha256": source.sha256,
"outputSha256": _sha256(canonical),
"sourceSizeBytes": len(source.raw),
"outputSizeBytes": len(canonical),
"unifiedDiff": diff_text,
"diffTruncated": truncated,
}
def _repair_confirmation_token(
*,
source_sha256: str,
output_sha256: str,
target: Path,
) -> str:
binding = {
"version": 1,
"sourceSha256": source_sha256,
"outputSha256": output_sha256,
"targetPath": os.path.normcase(str(target)),
}
return _sha256(
json.dumps(binding, ensure_ascii=False, sort_keys=True).encode("utf-8")
)
def _ensure_distinct_new_output(source: Path, output_text: str) -> Path:
output = Path(output_text).expanduser().resolve(strict=False)
if output == source.resolve(strict=True):
raise InputError("SOURCE_OVERWRITE_FORBIDDEN", "Output must not overwrite the source file.")
if output.exists():
raise InputError("OUTPUT_ALREADY_EXISTS", f"Output already exists: {output}")
return output
def _write_new_file(path: Path, data: bytes) -> None:
created = False
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("xb") as handle:
created = True
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
except OSError as exc:
try:
if created and path.exists():
path.unlink()
except OSError:
pass
raise ArtifactError("OUTPUT_WRITE_FAILED", str(exc), {"path": str(path)}) from exc
def command_repair_format(args: argparse.Namespace) -> int:
source = load_source(args.input, args.format)
if args.expected_sha256.casefold() != source.sha256:
raise InputError(
"SOURCE_HASH_MISMATCH",
"The source changed after the repair preview; inspect it again.",
{"expected": args.expected_sha256, "actual": source.sha256},
)
output = _ensure_distinct_new_output(source.path, args.output)
canonical = canonical_bytes(source)
inspection = inspect_source(source, base_url=args.base_url, timeout=args.timeout)
preview = _normalized_preview(source, canonical)
confirmation_token = _repair_confirmation_token(
source_sha256=source.sha256,
output_sha256=str(preview["outputSha256"]),
target=output,
)
preview["confirmationToken"] = confirmation_token
report: dict[str, object] = {
"ok": True,
"command": "repair-format",
"confirmed": bool(args.confirmed),
"changed": source.raw != canonical,
"source": inspection["source"],
"targetPath": str(output),
"textNormalization": inspection["textNormalization"],
"preview": preview,
}
if source.raw == canonical:
report["written"] = False
report["message"] = "The source is already in canonical text form."
emit_json(report)
return 0
if not args.confirmed:
report["written"] = False
report["requiresConfirmation"] = True
emit_json(report)
return 0
if not args.confirmation_token:
raise InputError(
"REPAIR_CONFIRMATION_TOKEN_REQUIRED",
"Confirmed repair requires the confirmation token returned by its preview.",
)
if args.confirmation_token != confirmation_token:
raise InputError(
"REPAIR_CONFIRMATION_TOKEN_MISMATCH",
"The repair preview no longer matches the source, normalized output, or target path.",
)
if source.format == "json":
candidate = SourceFile(
path=output,
raw=canonical,
text=canonical.decode("utf-8"),
encoding="utf-8",
had_bom=False,
sha256=_sha256(canonical),
format="json",
parsed=parse_json_text(canonical.decode("utf-8")),
)
else:
candidate = SourceFile(
path=output,
raw=canonical,
text=canonical.decode("utf-8"),
encoding="utf-8",
had_bom=False,
sha256=_sha256(canonical),
format="xml",
parsed=_parse_xml(canonical),
)
before_model = _semantic_model(source, base_url=args.base_url, timeout=args.timeout)
after_model = _semantic_model(candidate, base_url=args.base_url, timeout=args.timeout)
if before_model != after_model:
raise InputError(
"NORMALIZATION_CHANGED_MODEL",
"The candidate did not preserve the parsed model; no file was written.",
)
_write_new_file(output, canonical)
report["written"] = True
report["output"] = {
"path": str(output),
"sha256": _sha256(canonical),
"sizeBytes": len(canonical),
}
emit_json(report)
return 0
def _prepare_output_directory(path_text: str) -> Path:
path = Path(path_text).expanduser().resolve(strict=False)
if path.exists():
if not path.is_dir():
raise InputError("OUTPUT_DIR_NOT_DIRECTORY", f"Output path is not a directory: {path}")
try:
if any(path.iterdir()):
raise InputError(
"OUTPUT_DIR_NOT_EMPTY",
"Simulation output directory must be new or empty.",
{"path": str(path)},
)
except OSError as exc:
raise ArtifactError("OUTPUT_DIR_UNREADABLE", str(exc)) from exc
else:
try:
path.mkdir(parents=True)
except OSError as exc:
raise ArtifactError("OUTPUT_DIR_CREATE_FAILED", str(exc)) from exc
return path
def _project_name(inspection: Mapping[str, object]) -> str:
system = inspection.get("system")
if isinstance(system, Mapping):
name = system.get("name")
if isinstance(name, str) and name.strip():
return name.strip()
return "simulation"
def _available_variables(inspection: Mapping[str, object]) -> dict[str, dict[str, object]]:
system = inspection.get("system")
if not isinstance(system, Mapping):
return {}
variables = system.get("resultVariables")
if not isinstance(variables, list):
return {}
return {
str(variable["key"]): dict(variable)
for variable in variables
if isinstance(variable, dict) and isinstance(variable.get("key"), str)
}
def _json_to_xml(
source: SourceFile,
*,
base_url: str,
timeout: float,
) -> bytes:
raw, _ = http_request(
base_url,
"/api/reactflow/system-xml",
method="POST",
body=_json_request_bytes(source.parsed),
content_type="application/json",
timeout=timeout,
)
if not raw.strip().startswith(b"<?xml") and not raw.lstrip().startswith(b"<System"):
raise BackendError(
"BACKEND_XML_RESPONSE_INVALID",
"Project conversion did not return a System XML document.",
)
return raw
def _open_simulation_stream(
base_url: str,
xml: bytes,
simulation_id: str,
timeout: float,
):
request = urllib.request.Request(
_backend_url(base_url, "/api/system-xml/simulate-stream"),
data=xml,
method="POST",
headers={
"Accept": "application/x-ndjson",
"Content-Type": "application/xml",
"X-Simulation-Id": simulation_id,
},
)
try:
return urllib.request.urlopen(request, timeout=timeout)
except urllib.error.HTTPError as exc:
data = exc.read()
raise BackendError(
"BACKEND_HTTP_ERROR",
f"Backend returned HTTP {exc.code} before streaming started.",
_decode_backend_error(data, exc.code),
) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise BackendError("BACKEND_UNAVAILABLE", str(exc)) from exc
def _recover_task_snapshot(base_url: str, simulation_id: str, timeout: float) -> dict[str, object] | None:
try:
value = http_json(
base_url,
f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}",
timeout=timeout,
)
except BackendError:
return None
return value if isinstance(value, dict) else None
def _public_result_event(
event: Mapping[str, object],
simulation_id: str,
*,
full_result: str | None = "result.json",
) -> dict[str, object]:
"""Return result completion metadata without echoing every time-series sample."""
result = event.get("result")
if not isinstance(result, Mapping):
return dict(event)
variables = result.get("variables")
series = result.get("series")
times = series.get("time") if isinstance(series, Mapping) else None
actual_end_time = result.get("actualEndTime")
if actual_end_time is None:
actual_end_time = result.get("simulatedUntil")
summary: dict[str, object] = {
"event": "result",
"simulationId": event.get("simulationId") or simulation_id,
"status": result.get("status"),
"success": result.get("success"),
"actualEndTime": actual_end_time,
"sampleCount": len(times) if isinstance(times, list) else None,
"resultVariableCount": len(variables) if isinstance(variables, list) else None,
}
if full_result is not None:
summary["fullResult"] = full_result
return summary
def _should_emit_progress(
event: Mapping[str, object],
*,
previous_phase: str | None,
previous_progress: float | None,
seconds_since_emit: float,
) -> bool:
phase = str(event.get("phase") or "")
raw_progress = event.get("progress")
progress = (
float(raw_progress)
if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool)
else None
)
return (
previous_phase is None
or phase != previous_phase
or (progress is not None and previous_progress is not None and progress >= previous_progress + 5.0)
or progress == 100.0
or seconds_since_emit >= 5.0
)
def _read_simulation_stream(
base_url: str,
xml: bytes,
simulation_id: str,
timeout: float,
progress_path: Path,
) -> tuple[dict[str, object] | None, dict[str, object] | None]:
final_result: dict[str, object] | None = None
final_error: dict[str, object] | None = None
public_phase: str | None = None
public_progress: float | None = None
public_emit_time = 0.0
try:
progress = progress_path.open("x", encoding="utf-8", newline="\n")
except OSError as exc:
raise ArtifactError(
"PROGRESS_LOG_CREATE_FAILED",
str(exc),
{"path": str(progress_path)},
) from exc
try:
response = _open_simulation_stream(base_url, xml, simulation_id, timeout)
with response, progress:
for raw_line in response:
if not raw_line.strip():
continue
try:
event = json.loads(raw_line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SimulationError(
"SIMULATION_EVENT_INVALID",
"Simulation stream returned an invalid JSONL event.",
) from exc
if not isinstance(event, dict) or event.get("event") not in {
"progress",
"result",
"error",
}:
raise SimulationError(
"SIMULATION_EVENT_INVALID",
"Simulation stream returned an event outside the contract.",
event,
)
event_kind = event.get("event")
logged_event = (
_public_result_event(event, simulation_id)
if event_kind == "result"
else event
)
serialized = json.dumps(logged_event, ensure_ascii=False, allow_nan=False)
progress.write(serialized + "\n")
progress.flush()
if event_kind == "progress":
now = time.monotonic()
if _should_emit_progress(
event,
previous_phase=public_phase,
previous_progress=public_progress,
seconds_since_emit=now - public_emit_time,
):
emit_json(event)
public_phase = str(event.get("phase") or "")
raw_progress = event.get("progress")
if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool):
public_progress = float(raw_progress)
public_emit_time = now
elif event_kind == "result":
emit_json(logged_event)
else:
emit_json(event)
if event_kind == "result":
result = event.get("result")
if isinstance(result, dict):
final_result = result
else:
final_error = {
"code": "SIMULATION_RESULT_MISSING",
"message": "Result event did not contain a result object.",
}
elif event_kind == "error":
final_error = event
except SkillCliError:
progress.close()
raise
except (urllib.error.URLError, TimeoutError, OSError) as exc:
snapshot = _recover_task_snapshot(base_url, simulation_id, timeout)
if snapshot:
result = snapshot.get("result")
error = snapshot.get("error")
if isinstance(result, dict):
return result, error if isinstance(error, dict) else None
if isinstance(error, dict):
return None, error
raise BackendError(
"SIMULATION_STREAM_INTERRUPTED",
str(exc),
{"simulationId": simulation_id, "task": snapshot},
) from exc
return final_result, final_error
def _validate_result_shape(result: Mapping[str, object]) -> tuple[list[dict[str, object]], dict[str, list[float]]]:
raw_variables = result.get("variables")
raw_series = result.get("series")
if not isinstance(raw_variables, list) or not isinstance(raw_series, dict):
raise SimulationError(
"SIMULATION_RESULT_INVALID",
"Simulation result does not contain variables and series.",
)
variables = [dict(value) for value in raw_variables if isinstance(value, dict)]
series: dict[str, list[float]] = {}
for key, values in raw_series.items():
if not isinstance(key, str) or not isinstance(values, list):
raise SimulationError(
"SIMULATION_RESULT_INVALID",
"Simulation series must map string keys to numeric arrays.",
)
converted: list[float] = []
for value in values:
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(float(value)):
raise SimulationError(
"SIMULATION_RESULT_NONFINITE",
f"Series {key} contains a non-finite or non-numeric value.",
)
converted.append(float(value))
series[key] = converted
times = series.get("time")
if not times:
raise SimulationError("SIMULATION_RESULT_HAS_NO_TIME", "Simulation produced no usable time series.")
return variables, series
def _variable_metadata_by_key(variables: Sequence[Mapping[str, object]]) -> dict[str, dict[str, object]]:
return {
str(variable["key"]): dict(variable)
for variable in variables
if isinstance(variable.get("key"), str)
}
def _write_result_json(path: Path, result: Mapping[str, object]) -> None:
try:
data = (json.dumps(result, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8")
except (TypeError, ValueError) as exc:
raise SimulationError("SIMULATION_RESULT_NOT_JSON", str(exc)) from exc
_write_new_file(path, data)
def _download_csv(
base_url: str,
timeout: float,
project_name: str,
variables: Sequence[Mapping[str, object]],
series: Mapping[str, Sequence[float]],
) -> bytes:
raw, _ = http_request(
base_url,
"/api/simulation-results/csv",
method="POST",
body=_json_request_bytes(
{
"projectName": project_name,
"variables": list(variables),
"series": dict(series),
}
),
content_type="application/json",
timeout=timeout,
)
return raw
def _safe_stem(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return value[:80] or "curve"
def _decimated_points(times: Sequence[float], values: Sequence[float], maximum: int = 4000) -> list[tuple[float, float]]:
count = min(len(times), len(values))
if count <= maximum:
indices = range(count)
else:
indices = sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)})
return [(float(times[index]), float(values[index])) for index in indices]
def _domain(values: Iterable[float]) -> tuple[float, float]:
items = list(values)
minimum = min(items)
maximum = max(items)
if minimum == maximum:
padding = max(1.0, abs(minimum) * 0.05)
return minimum - padding, maximum + padding
padding = (maximum - minimum) * 0.05
return minimum - padding, maximum + padding
def _polyline(
points: Sequence[tuple[float, float]],
*,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
left: float,
top: float,
width: float,
height: float,
) -> str:
x_span = x_max - x_min or 1.0
y_span = y_max - y_min or 1.0
return " ".join(
f"{left + (x - x_min) * width / x_span:.2f},{top + height - (y - y_min) * height / y_span:.2f}"
for x, y in points
)
def _variable_label(variable: Mapping[str, object]) -> str:
component = str(variable.get("componentId", "component"))
port = variable.get("portName")
label = str(variable.get("label") or variable.get("name") or variable.get("key"))
return f"{component}.{port}.{label}" if port else f"{component}.{label}"
def _svg_document(
times: Sequence[float],
selected: Sequence[tuple[Mapping[str, object], Sequence[float]]],
*,
stacked: bool,
) -> str:
width = 1000
panel_height = 280
height = panel_height * len(selected) if stacked else 540
left, right = 82.0, 32.0
plot_width = width - left - right
x_min, x_max = _domain(times)
panels = list(enumerate(selected)) if stacked else [(0, selected[0])]
parts = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
'<rect width="100%" height="100%" fill="#ffffff"/>',
'<style>text{font-family:Arial,"Microsoft YaHei",sans-serif;fill:#1f2937}.axis{stroke:#64748b;stroke-width:1}.grid{stroke:#e2e8f0;stroke-width:1}.curve{fill:none;stroke-width:2}</style>',
]
if stacked:
for panel_index, (variable, values) in panels:
top = panel_index * panel_height + 34.0
plot_height = panel_height - 78.0
y_min, y_max = _domain(values)
parts.extend(_svg_panel_axes(left, top, plot_width, plot_height, x_min, x_max, y_min, y_max))
points = _decimated_points(times, values)
coords = _polyline(points, x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, left=left, top=top, width=plot_width, height=plot_height)
label = html.escape(_variable_label(variable))
unit = html.escape(str(variable.get("unit", "")))
parts.append(f'<text x="{left}" y="{top - 12:.1f}" font-size="15">{label} [{unit}]</text>')
parts.append(f'<polyline class="curve" stroke="{CHART_COLORS[panel_index % len(CHART_COLORS)]}" points="{coords}"/>')
else:
top = 60.0
plot_height = height - 130.0
all_values = [value for _, values in selected for value in values]
y_min, y_max = _domain(all_values)
parts.extend(_svg_panel_axes(left, top, plot_width, plot_height, x_min, x_max, y_min, y_max))
for index, (variable, values) in enumerate(selected):
coords = _polyline(_decimated_points(times, values), x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, left=left, top=top, width=plot_width, height=plot_height)
color = CHART_COLORS[index % len(CHART_COLORS)]
parts.append(f'<polyline class="curve" stroke="{color}" points="{coords}"/>')
legend_y = 23 + index * 20
label = html.escape(_variable_label(variable))
parts.append(f'<line x1="{left}" y1="{legend_y}" x2="{left + 28}" y2="{legend_y}" stroke="{color}" stroke-width="3"/>')
parts.append(f'<text x="{left + 36}" y="{legend_y + 5}" font-size="14">{label}</text>')
parts.append('</svg>')
return "\n".join(parts) + "\n"
def _svg_panel_axes(
left: float,
top: float,
width: float,
height: float,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
) -> list[str]:
parts: list[str] = []
for index in range(6):
x = left + width * index / 5
y = top + height * index / 5
x_value = x_min + (x_max - x_min) * index / 5
y_value = y_max - (y_max - y_min) * index / 5
parts.append(f'<line class="grid" x1="{x:.2f}" y1="{top:.2f}" x2="{x:.2f}" y2="{top + height:.2f}"/>')
parts.append(f'<line class="grid" x1="{left:.2f}" y1="{y:.2f}" x2="{left + width:.2f}" y2="{y:.2f}"/>')
parts.append(f'<text x="{x:.2f}" y="{top + height + 22:.2f}" text-anchor="middle" font-size="12">{x_value:.5g}</text>')
parts.append(f'<text x="{left - 10:.2f}" y="{y + 4:.2f}" text-anchor="end" font-size="12">{y_value:.5g}</text>')
parts.append(f'<line class="axis" x1="{left:.2f}" y1="{top + height:.2f}" x2="{left + width:.2f}" y2="{top + height:.2f}"/>')
parts.append(f'<line class="axis" x1="{left:.2f}" y1="{top:.2f}" x2="{left:.2f}" y2="{top + height:.2f}"/>')
parts.append(f'<text x="{left + width / 2:.2f}" y="{top + height + 43:.2f}" text-anchor="middle" font-size="13">time [s]</text>')
return parts
def _write_charts(
output_dir: Path,
mode: str,
selected_keys: Sequence[str],
variables: Mapping[str, Mapping[str, object]],
series: Mapping[str, Sequence[float]],
) -> list[str]:
times = series["time"]
selected = [(variables[key], series[key]) for key in selected_keys]
artifacts: list[str] = []
if mode == "overlay":
units = {str(variable.get("unit", "")) for variable, _ in selected}
if len(units) > 1:
raise InputError(
"OVERLAY_UNITS_MISMATCH",
"Overlay requires all selected result variables to use the same unit; use stacked instead.",
{"units": sorted(units)},
)
path = output_dir / "curves-overlay.svg"
_write_new_file(path, _svg_document(times, selected, stacked=False).encode("utf-8"))
artifacts.append(str(path))
elif mode == "stacked":
path = output_dir / "curves-stacked.svg"
_write_new_file(path, _svg_document(times, selected, stacked=True).encode("utf-8"))
artifacts.append(str(path))
else:
for variable, values in selected:
key = str(variable.get("key", "curve"))
key_digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
path = output_dir / f"curve-{_safe_stem(key)}-{key_digest}.svg"
_write_new_file(path, _svg_document(times, [(variable, values)], stacked=False).encode("utf-8"))
artifacts.append(str(path))
return artifacts
def command_simulate(args: argparse.Namespace) -> int:
source = load_source(args.input, args.format)
inspection = inspect_source(
source,
base_url=args.base_url,
timeout=args.timeout,
variable_limit=1_000_000,
)
available = _available_variables(inspection)
requested = list(dict.fromkeys(args.variables))
missing = [key for key in requested if key not in available]
if missing:
raise InputError(
"RESULT_VARIABLE_UNKNOWN",
"One or more requested result keys are not declared by the compiled model.",
{"missing": missing, "available": sorted(available)},
)
if args.chart_mode == "overlay":
units = {str(available[key].get("unit", "")) for key in requested}
if len(units) > 1:
raise InputError(
"OVERLAY_UNITS_MISMATCH",
"Overlay requires result variables with the same unit; choose stacked instead.",
{"units": sorted(units)},
)
simulation_id = args.simulation_id or uuid.uuid4().hex
if not SIMULATION_ID_PATTERN.fullmatch(simulation_id):
raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.")
output_dir = _prepare_output_directory(args.output_dir)
xml = source.raw if source.format == "xml" else _json_to_xml(source, base_url=args.base_url, timeout=args.timeout)
emit_json(
{
"event": "submitted",
"simulationId": simulation_id,
"input": str(source.path),
"selectedVariables": requested,
"chartMode": args.chart_mode,
"outputDirectory": str(output_dir),
}
)
result, error = _read_simulation_stream(
args.base_url,
xml,
simulation_id,
args.timeout,
output_dir / "progress.jsonl",
)
if result is None:
raise SimulationError(
"SIMULATION_FAILED_WITHOUT_RESULT",
"Simulation ended without a usable result.",
{"simulationId": simulation_id, "backendError": error},
)
result_path = output_dir / "result.json"
_write_result_json(result_path, result)
variables, series = _validate_result_shape(result)
metadata = _variable_metadata_by_key(variables)
missing_after_run = [key for key in requested if key not in metadata or key not in series]
if missing_after_run:
raise SimulationError(
"RESULT_VARIABLE_MISSING",
"Requested variables were declared before the run but are missing from the result.",
{"missing": missing_after_run},
)
csv_path = output_dir / "results.csv"
csv_bytes = _download_csv(
args.base_url,
args.timeout,
_project_name(inspection),
variables,
series,
)
_write_new_file(csv_path, csv_bytes)
chart_paths = _write_charts(
output_dir,
args.chart_mode,
requested,
metadata,
series,
)
status = str(result.get("status") or ("completed" if result.get("success") else "failed"))
artifact_event = {
"event": "artifacts",
"simulationId": simulation_id,
"status": status,
"partial": status != "completed",
"result": str(result_path),
"csv": str(csv_path),
"charts": chart_paths,
"progress": str(output_dir / "progress.jsonl"),
"selectedVariables": requested,
}
emit_json(artifact_event)
return 0 if status == "completed" and bool(result.get("success")) else 4
def _public_task_snapshot(task: Mapping[str, object]) -> dict[str, object]:
snapshot = {key: value for key, value in task.items() if key != "result"}
result = task.get("result")
if isinstance(result, Mapping):
simulation_id = str(task.get("simulationId") or "unknown")
snapshot["resultSummary"] = _public_result_event(
{"event": "result", "result": result},
simulation_id,
full_result=None,
)
return snapshot
def command_status(args: argparse.Namespace) -> int:
simulation_id = args.simulation_id
if not SIMULATION_ID_PATTERN.fullmatch(simulation_id):
raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.")
result = http_json(
args.base_url,
f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}",
timeout=args.timeout,
)
task = _expect_mapping(
result,
code="BACKEND_TASK_RESPONSE_INVALID",
message="Simulation task response must be an object.",
)
emit_json({"ok": True, "command": "status", "task": _public_task_snapshot(task)})
return 0
def command_cancel(args: argparse.Namespace) -> int:
simulation_id = args.simulation_id
if not SIMULATION_ID_PATTERN.fullmatch(simulation_id):
raise InputError("SIMULATION_ID_INVALID", "Simulation ID must match [A-Za-z0-9._-]{1,128}.")
result = http_json(
args.base_url,
f"/api/system-xml/simulations/{urllib.parse.quote(simulation_id, safe='')}/cancel",
method="POST",
payload={"reason": args.reason},
timeout=args.timeout,
)
emit_json({"ok": True, "command": "cancel", "task": result})
return 0
def positive_timeout(value: str) -> float:
try:
parsed = float(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("timeout must be numeric") from exc
if not math.isfinite(parsed) or parsed < MIN_TIMEOUT_SECONDS:
raise argparse.ArgumentTypeError(
f"timeout must be finite and at least {MIN_TIMEOUT_SECONDS:g} seconds "
"so it remains above the backend heartbeat interval"
)
return parsed
def nonnegative_integer(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("value must be an integer") from exc
if parsed < 0:
raise argparse.ArgumentTypeError("value must be zero or greater")
return parsed
def page_limit(value: str) -> int:
parsed = nonnegative_integer(value)
if parsed < 1 or parsed > 200:
raise argparse.ArgumentTypeError("page limit must be between 1 and 200")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect, safely normalize and simulate SystemSimulationApp JSON/XML files."
)
parser.add_argument(
"--base-url",
default=DEFAULT_BASE_URL,
help="Loopback FastAPI base URL (default: %(default)s).",
)
parser.add_argument(
"--timeout",
default=DEFAULT_TIMEOUT_SECONDS,
type=positive_timeout,
help="HTTP/heartbeat read timeout in seconds (default: %(default)s).",
)
subparsers = parser.add_subparsers(dest="command", required=True)
inspect_parser = subparsers.add_parser("inspect", help="Validate and summarize a project file.")
inspect_parser.add_argument("input")
inspect_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto")
inspect_parser.add_argument(
"--component",
action="append",
default=[],
help="Include full compiled details for this component ID (repeatable).",
)
inspect_parser.add_argument(
"--variable-query",
help="Filter result variables by key, component, label, quantity or unit.",
)
inspect_parser.add_argument(
"--variable-offset",
type=nonnegative_integer,
default=0,
help="Result-variable page offset (default: %(default)s).",
)
inspect_parser.add_argument(
"--variable-limit",
type=page_limit,
default=20,
help="Result-variable page size, 1-200 (default: %(default)s).",
)
inspect_parser.add_argument(
"--component-offset",
type=nonnegative_integer,
default=0,
help="Component-summary page offset (default: %(default)s).",
)
inspect_parser.add_argument(
"--component-limit",
type=page_limit,
default=50,
help="Component-summary page size, 1-200 (default: %(default)s).",
)
inspect_parser.add_argument(
"--connection-offset",
type=nonnegative_integer,
default=0,
help="Connection page offset (default: %(default)s).",
)
inspect_parser.add_argument(
"--connection-limit",
type=page_limit,
default=25,
help="Connection page size, 1-200 (default: %(default)s).",
)
inspect_parser.set_defaults(handler=command_inspect)
repair_parser = subparsers.add_parser(
"repair-format",
help="Preview or write a semantics-preserving UTF-8/LF normalization.",
)
repair_parser.add_argument("input")
repair_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto")
repair_parser.add_argument("--output", required=True)
repair_parser.add_argument("--expected-sha256", required=True)
repair_parser.add_argument(
"--confirmation-token",
help="Token from the matching preview; required together with --confirmed.",
)
repair_parser.add_argument(
"--confirmed",
action="store_true",
help="Write the new file. Without this flag, only return a diff preview.",
)
repair_parser.set_defaults(handler=command_repair_format)
simulate_parser = subparsers.add_parser("simulate", help="Run and monitor a simulation.")
simulate_parser.add_argument("input")
simulate_parser.add_argument("--format", choices=("auto", "json", "xml"), default="auto")
simulate_parser.add_argument("--output-dir", required=True)
simulate_parser.add_argument("--variables", nargs="+", required=True)
simulate_parser.add_argument(
"--chart-mode",
choices=("separate", "overlay", "stacked"),
required=True,
)
simulate_parser.add_argument("--simulation-id")
simulate_parser.set_defaults(handler=command_simulate)
status_parser = subparsers.add_parser("status", help="Read a known simulation task.")
status_parser.add_argument("simulation_id")
status_parser.set_defaults(handler=command_status)
cancel_parser = subparsers.add_parser("cancel", help="Cancel a known simulation task.")
cancel_parser.add_argument("simulation_id")
cancel_parser.add_argument("--reason", choices=("user", "stalled"), default="user")
cancel_parser.set_defaults(handler=command_cancel)
return parser
def main(argv: Sequence[str] | None = None) -> int:
configure_standard_streams()
parser = build_parser()
args = parser.parse_args(argv)
try:
args.base_url = validate_base_url(args.base_url)
return int(args.handler(args))
except SkillCliError as exc:
emit_json(stable_error_payload(exc), stream=sys.stderr)
return exc.exit_code
except KeyboardInterrupt:
emit_json(
stable_error_payload(
SimulationError(
"CLIENT_INTERRUPTED",
"The local monitor was interrupted. The backend task may still be running; query its simulation ID.",
)
),
stream=sys.stderr,
)
return 4
if __name__ == "__main__":
raise SystemExit(main())