1934 lines
67 KiB
Python
1934 lines
67 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):
|
|
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)
|
|
)
|
|
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())
|