19 changed files with 11207 additions and 3 deletions

No files matched your search

+7
View File
@@ -1,3 +1,10 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
# Regression manifests hash these files as raw bytes. Keep their checkout
# representation identical on Windows and Linux so hashes remain portable.
tests/data/test-mql-8.xml text eol=lf
tests/data/test-mql-8.json text eol=lf
tests/data/test_mql-full-branches-01-04.xml text eol=lf
tests/baselines/simulation/**/*.json text eol=lf
+23
View File
@@ -8,6 +8,7 @@ on:
- "requirements.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
@@ -17,6 +18,7 @@ on:
- "requirements.txt"
- "constraints/**"
- ".python-version"
- ".gitattributes"
- "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
@@ -60,6 +62,27 @@ permissions:
contents: read
jobs:
fixture-byte-contract:
if: >-
github.event_name == 'push' ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
strategy:
fail-fast: false
matrix:
os:
- ubuntu-24.04
- windows-2022
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
- name: Verify portable regression fixture bytes
run: python -m unittest tests.test_regression_fixture_line_endings
quick:
if: >-
github.event_name == 'push' ||
+46 -3
View File
@@ -22,6 +22,11 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.parameter_expression import (
ParameterExpressionError,
evaluate_parameter_expression,
expression_value_to_base_unit,
)
from app.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.property_cache import property_cache_run
from app.simulation.solvers.solver import SolverActivityTracker
@@ -1100,7 +1105,14 @@ def validate_reactflow_component_contract(
parameter_values: dict[str, float] = {}
for parameter in component_spec.parameters:
value = parameter_float(node, parameter.name, parameter.default)
value = parameter_float(
node,
parameter.name,
parameter.default,
quantity=parameter.quantity,
base_unit=parameter.unit,
expressions_allowed=parameter.editor is None,
)
validation_message = parameter.validation_message(value)
if validation_message is not None:
raise ValueError(
@@ -1667,14 +1679,45 @@ def parameter_float(
node: ReactFlowNodePayload | None,
name: str,
default: float,
*,
quantity: str = "dimensionless",
base_unit: str = "",
expressions_allowed: bool = True,
) -> float:
if node is None:
return default
value = node.data.parameters.get(name, default)
try:
return float(value)
numeric_value = float(value)
except (TypeError, ValueError):
raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.")
if not isinstance(value, str):
raise ValueError(
f"Parameter '{name}' on component '{node.id}' must be numeric."
)
if not expressions_allowed:
raise ValueError(
f"PARAMETER_EXPRESSION_FORBIDDEN: Parameter '{name}' on component "
f"'{node.id}' is a discrete selection and cannot use an expression."
)
try:
evaluated = evaluate_parameter_expression(value)
selected_unit = node.data.parameterUnits.get(name, base_unit)
return expression_value_to_base_unit(
evaluated,
quantity=quantity,
selected_unit=selected_unit,
)
except ParameterExpressionError as exc:
raise ValueError(
f"PARAMETER_EXPRESSION_INVALID: Parameter '{name}' on component "
f"'{node.id}' contains an invalid expression: {exc}."
) from exc
if not isfinite(numeric_value):
raise ValueError(
f"Parameter '{name}' on component '{node.id}' must be a finite "
"numeric value."
)
return numeric_value
def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type):
+408
View File
@@ -0,0 +1,408 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Callable
MAX_INPUT_LENGTH = 512
MAX_TOKEN_COUNT = 256
MAX_OPERATION_COUNT = 256
MAX_NESTING_DEPTH = 32
MAX_FUNCTION_ARGUMENTS = 16
_UNSIGNED_NUMBER_PREFIX = re.compile(
r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
)
class ParameterExpressionError(ValueError):
"""Raised when an editor parameter expression cannot be resolved safely."""
@dataclass(frozen=True)
class _Token:
kind: str
text: str
position: int
value: float | None = None
def evaluate_parameter_expression(expression: str) -> float:
"""Evaluate the same bounded arithmetic subset accepted by the frontend.
The parser never executes Python code and cannot access names other than
the constants ``pi`` and ``e`` or the explicitly supported functions.
"""
source = expression.strip()
if source.startswith("="):
source = source[1:].strip()
if not source:
raise ParameterExpressionError("expression must not be empty")
if len(source) > MAX_INPUT_LENGTH:
raise ParameterExpressionError(
f"expression must not exceed {MAX_INPUT_LENGTH} characters"
)
return _ParameterExpressionParser(_tokenize(source)).parse()
def expression_value_to_base_unit(
value: float,
*,
quantity: str,
selected_unit: str,
) -> float:
"""Convert an expression result from its editor unit to the SI contract.
Plain numeric JSON values are already stored in SI and must not pass
through this function. Only expression results use the selected display
unit, matching the existing frontend behavior.
"""
conversions = _UNIT_CONVERSIONS.get(quantity)
if not conversions:
return _ensure_finite(value, "expression result")
conversion = conversions.get(selected_unit)
if conversion is None:
# The frontend falls back to the first (base) unit for an unknown or
# absent selection. Keep the execution boundary behavior identical.
conversion = next(iter(conversions.values()))
scale, offset = conversion
return _ensure_finite(value * scale + offset, "converted expression result")
def _tokenize(source: str) -> tuple[_Token, ...]:
tokens: list[_Token] = []
position = 0
def append(token: _Token) -> None:
tokens.append(token)
if len(tokens) > MAX_TOKEN_COUNT:
raise ParameterExpressionError(
f"expression must not exceed {MAX_TOKEN_COUNT} tokens"
)
while position < len(source):
character = source[position]
if character.isspace():
position += 1
continue
if character.isdigit() or (
character == "."
and position + 1 < len(source)
and source[position + 1].isdigit()
):
match = _UNSIGNED_NUMBER_PREFIX.match(source, position)
if match is None:
raise ParameterExpressionError(
f"invalid number near character {position + 1}"
)
text = match.group(0)
value = _ensure_finite(float(text), f"number {text!r}")
append(_Token("number", text, position, value))
position = match.end()
continue
if character.isascii() and (character.isalpha() or character == "_"):
end = position + 1
while end < len(source):
candidate = source[end]
if not candidate.isascii() or not (
candidate.isalnum() or candidate == "_"
):
break
end += 1
append(_Token("identifier", source[position:end], position))
position = end
continue
if character == "*" and source[position : position + 2] == "**":
append(_Token("operator", "**", position))
position += 2
continue
if character in "+-*/^":
append(_Token("operator", character, position))
position += 1
continue
if character == "(":
append(_Token("left_parenthesis", character, position))
position += 1
continue
if character == ")":
append(_Token("right_parenthesis", character, position))
position += 1
continue
if character == ",":
append(_Token("comma", character, position))
position += 1
continue
raise ParameterExpressionError(
f"unsupported symbol {character!r} at character {position + 1}"
)
tokens.append(_Token("end", "", len(source)))
return tuple(tokens)
class _ParameterExpressionParser:
def __init__(self, tokens: tuple[_Token, ...]) -> None:
self._tokens = tokens
self._index = 0
self._operation_count = 0
def parse(self) -> float:
value = self._parse_additive(0)
trailing = self._current()
if trailing.kind != "end":
raise ParameterExpressionError(
f"unexpected content {trailing.text!r} near character "
f"{trailing.position + 1}"
)
return _ensure_finite(value, "expression result")
def _parse_additive(self, depth: int) -> float:
value = self._parse_multiplicative(depth)
while self._is_operator("+") or self._is_operator("-"):
operator = self._advance().text
right = self._parse_multiplicative(depth)
self._count_operation()
value = _safe_operation(
lambda: value + right if operator == "+" else value - right,
f"operation {operator!r}",
)
return value
def _parse_multiplicative(self, depth: int) -> float:
value = self._parse_unary(depth)
while self._is_operator("*") or self._is_operator("/"):
operator = self._advance().text
right = self._parse_unary(depth)
self._count_operation()
if operator == "/" and right == 0:
raise ParameterExpressionError("division by zero is not allowed")
value = _safe_operation(
lambda: value * right if operator == "*" else value / right,
f"operation {operator!r}",
)
return value
def _parse_unary(self, depth: int) -> float:
self._assert_depth(depth)
if self._is_operator("+") or self._is_operator("-"):
operator = self._advance().text
self._count_operation()
operand = self._parse_unary(depth + 1)
return _ensure_finite(
operand if operator == "+" else -operand,
f"unary operation {operator!r}",
)
return self._parse_power(depth)
def _parse_power(self, depth: int) -> float:
self._assert_depth(depth)
base = self._parse_primary(depth)
if not self._is_operator("^") and not self._is_operator("**"):
return base
operator = self._advance().text
exponent = self._parse_unary(depth + 1)
self._count_operation()
return _safe_operation(
lambda: math.pow(base, exponent),
f"operation {operator!r}",
)
def _parse_primary(self, depth: int) -> float:
self._assert_depth(depth)
token = self._current()
if token.kind == "number":
self._advance()
return _ensure_finite(
token.value if token.value is not None else math.nan,
f"number {token.text!r}",
)
if token.kind == "identifier":
self._advance()
normalized_name = token.text.casefold()
if self._current().kind == "left_parenthesis":
return self._parse_function_call(
normalized_name,
token.text,
depth + 1,
)
if normalized_name == "pi":
return math.pi
if normalized_name == "e":
return math.e
raise ParameterExpressionError(f"unknown identifier {token.text!r}")
if token.kind == "left_parenthesis":
self._advance()
value = self._parse_additive(depth + 1)
self._expect("right_parenthesis", "missing closing parenthesis")
return value
if token.kind == "end":
raise ParameterExpressionError(
"expression ends before a number, constant, or function"
)
raise ParameterExpressionError(
f"expected a number, constant, or function near character "
f"{token.position + 1}"
)
def _parse_function_call(
self,
normalized_name: str,
source_name: str,
depth: int,
) -> float:
self._assert_depth(depth)
self._expect(
"left_parenthesis",
f"function {source_name} is missing an opening parenthesis",
)
arguments: list[float] = []
if self._current().kind != "right_parenthesis":
while True:
if len(arguments) >= MAX_FUNCTION_ARGUMENTS:
raise ParameterExpressionError(
f"function {source_name} accepts at most "
f"{MAX_FUNCTION_ARGUMENTS} arguments"
)
arguments.append(self._parse_additive(depth))
if self._current().kind != "comma":
break
self._advance()
if self._current().kind == "right_parenthesis":
raise ParameterExpressionError(
f"function {source_name} has no argument after its comma"
)
self._expect(
"right_parenthesis",
f"function {source_name} is missing a closing parenthesis",
)
self._count_operation()
return _evaluate_function(normalized_name, source_name, arguments)
def _current(self) -> _Token:
return self._tokens[min(self._index, len(self._tokens) - 1)]
def _advance(self) -> _Token:
token = self._current()
if token.kind != "end":
self._index += 1
return token
def _expect(self, kind: str, message: str) -> _Token:
if self._current().kind != kind:
raise ParameterExpressionError(message)
return self._advance()
def _is_operator(self, operator: str) -> bool:
token = self._current()
return token.kind == "operator" and token.text == operator
def _assert_depth(self, depth: int) -> None:
if depth > MAX_NESTING_DEPTH:
raise ParameterExpressionError(
f"expression nesting must not exceed {MAX_NESTING_DEPTH} levels"
)
def _count_operation(self) -> None:
self._operation_count += 1
if self._operation_count > MAX_OPERATION_COUNT:
raise ParameterExpressionError(
f"expression must not exceed {MAX_OPERATION_COUNT} operations"
)
def _evaluate_function(
normalized_name: str,
source_name: str,
arguments: list[float],
) -> float:
def require_count(expected: int) -> None:
if len(arguments) != expected:
raise ParameterExpressionError(
f"function {source_name} requires {expected} arguments, "
f"received {len(arguments)}"
)
if normalized_name == "sqrt":
require_count(1)
if arguments[0] < 0:
raise ParameterExpressionError("sqrt argument must not be negative")
operation = lambda: math.sqrt(arguments[0])
elif normalized_name == "abs":
require_count(1)
operation = lambda: abs(arguments[0])
elif normalized_name in {"sin", "cos", "tan", "asin", "acos", "atan"}:
require_count(1)
if normalized_name in {"asin", "acos"} and not -1 <= arguments[0] <= 1:
raise ParameterExpressionError(
f"{source_name} argument must be between -1 and 1"
)
function = getattr(math, normalized_name)
operation = lambda: function(arguments[0])
elif normalized_name == "exp":
require_count(1)
operation = lambda: math.exp(arguments[0])
elif normalized_name in {"ln", "log"}:
require_count(1)
if arguments[0] <= 0:
raise ParameterExpressionError(f"{source_name} argument must be positive")
operation = lambda: math.log(arguments[0])
elif normalized_name == "log10":
require_count(1)
if arguments[0] <= 0:
raise ParameterExpressionError("log10 argument must be positive")
operation = lambda: math.log10(arguments[0])
elif normalized_name in {"min", "max"}:
if not arguments:
raise ParameterExpressionError(
f"function {source_name} requires at least one argument"
)
function = min if normalized_name == "min" else max
operation = lambda: float(function(arguments))
elif normalized_name == "pow":
require_count(2)
operation = lambda: math.pow(arguments[0], arguments[1])
else:
raise ParameterExpressionError(f"unsupported function {source_name!r}")
return _safe_operation(operation, f"function {source_name}")
def _safe_operation(operation: Callable[[], float], context: str) -> float:
try:
value = operation()
except (ArithmeticError, ValueError) as exc:
raise ParameterExpressionError(f"{context} has no finite real result") from exc
return _ensure_finite(float(value), context)
def _ensure_finite(value: float, context: str) -> float:
if not math.isfinite(value):
raise ParameterExpressionError(f"{context} is not finite")
return value
# Ordered exactly like the editor's unit selector. The first entry is the
# fallback SI unit when a persisted selection is absent or unknown.
_UNIT_CONVERSIONS: dict[str, dict[str, tuple[float, float]]] = {
"area": {"m2": (1.0, 0.0), "cm2": (1.0e-4, 0.0), "mm2": (1.0e-6, 0.0)},
"heat_transfer_coefficient": {"W/(m2*K)": (1.0, 0.0)},
"pressure": {
"Pa": (1.0, 0.0),
"kPa": (1.0e3, 0.0),
"MPa": (1.0e6, 0.0),
"bar": (1.0e5, 0.0),
},
"volume": {"m3": (1.0, 0.0), "L": (1.0e-3, 0.0), "mL": (1.0e-6, 0.0)},
"temperature": {"K": (1.0, 0.0), "degC": (1.0, 273.15)},
"length": {"m": (1.0, 0.0), "cm": (1.0e-2, 0.0), "mm": (1.0e-3, 0.0)},
}
+4
View File
@@ -12,6 +12,10 @@
新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
## 重点实施计划
- [C 语言数值内核实施计划与可行性评估](other/C语言数值内核实施计划与可行性评估.md):后端数值内核的分阶段迁移顺序、验收门、风险和可行性评估。
## `update-log` 书写规范
以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
@@ -0,0 +1,406 @@
# C 语言数值内核实施计划与可行性评估
> 文档状态:待评审实施方案
> 建立日期:2026-09-02
> 适用分支:`model-development`
> 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md)
> 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C
## 1. 结论
这条路线值得实施,但正确的目标不是“把现有 Python 代码逐行翻译成 C”,而是:
1. 保留 Python 作为模型解析、网络编译、任务管理、结果服务和正确性参考;
2. 先把完整的一次 RHS 计算编译成无 Python 回调的扁平数值 IR;
3. 再让 C 内核一次完成整次 RHS、stream、物性、Jacobian 和事件计算;
4. Python 旧引擎长期保留为不支持模型的兼容路径和故障回退路径。
总体技术可行性为**中高**。仓库已经具备因果计划、参考 IR、稀疏 Jacobian 结构、性能埋点和 AMESim 回归基础,因此不是从零开始。主要难点不是 C 语法,而是完整冻结当前模型的计算顺序、副作用、事件和回滚语义。
“实现稳定的 C 数值后端”具有较高可能性;“仅靠换成 C 就达到接近 AMESim 的速度”目前不能承诺。高刚度 LSTP 接触会让积分器产生大量纳秒级小步,有限差分 Jacobian 又会重复调用 RHS。C 能降低每次计算的成本,但不会自动减少这些计算次数。真正的大幅收益需要同时完成:
- 完整扁平 IR;
- 整体 C RHS;
- 稀疏解析或半解析 Jacobian;
- 正确的接触事件和模式切换;
- 必要时再评估原生积分器。
## 2. 推荐的最终边界
```text
XML / JSON
↓
Python:解析、校验、组件注册、网络编译
↓
完整扁平 IR:槽位、阶段、依赖、模式、事件、Jacobian 结构
↓ 一次跨语言调用完成整次计算
C 数值内核:RHS / stream / 物性 / Jacobian / Event
↓
SciPy BDF(首阶段保留)或后续通过验证的原生积分器
↓
Python:采样、结果编码、API、任务状态和诊断
```
以下边界必须坚持:
- 不在每个组件上来回调用 Python 和 C;一次 RHS 最多进行一次主要跨语言调用。
- C 兼容模型的热路径内不得调用 Python callback。
- C 内核不直接解析 XML,不管理 HTTP,也不承担组件编辑和数据库职责。
- 自定义 Python 组件不能静默降级为跨语言逐组件调用;只要整模型不满足原生能力合同,就明确走 Python 引擎。
- 迁移期间始终保留 `python`、`native`、`shadow` 三种模式;通过长时发布门后才能增加 `auto` 默认选择。
建议统一配置为:
```text
SIMULATION_NUMERIC_ENGINE=python|native|shadow|auto
```
其中 `shadow` 由 Python 控制正式积分,C 只接收相同输入并逐阶段比较,不允许影响 Python 工作区。
## 3. 当前基础与首个阻断
### 3.1 已有基础
- `causal_ir.py` 已有 schema v1、结构签名、预分配工作区、阶段观察和事务回滚原型。
- 当前因果执行器已经消去一部分重复逻辑坐标,并证明了数组化和预编译执行计划的方向有效。
- 已有 Jacobian 稀疏图、局部切向原语和显式实验回退路径。
- 已有 AMESim 结果读取、物理投影、递进时域 runner、性能统计、取消和故障注入测试。
- 当前主模型约有 156 个运行组件、20 种组件类型和 132 个连续状态,适合用“能力矩阵 + 分阶段覆盖”推进。
### 3.2 当前 IR 的缺口
现有 schema v1 只覆盖全局因果代数计划,绑定仍依赖 Python 的 reader、writer 和 evaluator 回调。以下内容尚未进入同一个无回调执行计划:
- 状态写入和信号传播;
- dynamic volume 和物性状态包;
- secondary 压力/流量块;
- stream SCC/DAG 和热流体外层闭合;
- 状态导数;
- 离散 mode、事件检测、reset 和重启;
- Jacobian 数值填充;
- 结果投影。
因此,当前 IR 不能直接包一层 C 接口后就获得预期收益。
### 3.3 P0 跨平台换行阻断(已解决)
当前 Windows 工作树中的权威输入按原始字节计算时与 manifest 不一致;进一步核对确认,差异全部来自 Git 检出后的 `CRLF` 换行,而不是模型内容变化:
| 文件 | Windows 工作树原始字节 | 将 `CRLF` 还原为 `LF` 后 | manifest 记录值 |
| --- | --- | --- | --- |
| `tests/data/test-mql-8.xml` | 106,188 B;2,078 个 `CRLF`;SHA-256 `e7eef641...b801` | 104,110 B;`0a2d9331...7b0b` | 104,110 B;`0a2d9331...7b0b` |
| `tests/data/test-mql-8.json` | 282,796 B;10,204 个 `CRLF`;SHA-256 `51e1acb3...11e2` | 272,592 B;`b44bf540...fbe0` | 272,592 B;`b44bf540...fbe0` |
| `AmesimModels/test_mql.ame` | 21,708,800 B;`cbc3aadd...c20fbb` | 不适用 | 一致 |
因此没有证据表明 fixture 语义发生漂移,也不应仅因这一差异重建 golden。真正的问题是:manifest 按原始字节锁定输入,而 `.gitattributes` 没有固定这两个文本 fixture 的换行;当前加载器又直接校验工作树原始字节,所以同一提交在 Windows 上可能被拒绝、在 Linux 上通过。
该阻断已于 2026-09-02 完成本地修复:`.gitattributes` 已将两个主模型输入、历史 0.81 s 输入和 `tests/baselines/simulation` 下的 JSON 证据固定为 `LF`,当前 Windows 工作树也已从 Git 索引重新检出为 `LF`。自动测试会同时检查属性规则、禁止原始 `CR` 字节,并按 manifest 复核输入大小和 SHA-256;CI 已配置为在 Ubuntu 和 Windows 上分别执行这一合同,远端运行证据待提交后取得。
本地验收显示这些文件均为 `index=LF / worktree=LF / eol=lf`,两个 regression manifest 可在 Windows 正常完整加载,现有 manifest 和 golden 无需更新。未来只有在规范化后的内容确实变化时,才进入“模型变更、重建 manifest/golden”的流程。历史性能数字仍需按其代码版本看待,但不因换行差异失效。
## 4. 按重要性排序的实施计划
优先级定义:
- `P0`:正确性和架构前置,不完成就不能安全编写生产 C 内核;
- `P1`:形成可用且有明显收益的 C 后端;
- `P2`:P1 证明有效后再实施的增强项。
表中的工期是单名熟悉现有求解器的开发者的粗略有效工作量,不是交付承诺,也不包含长时仿真排队时间。
| 顺序 | ID | 优先级 | 工作项 | 可行性 | 粗略工作量 | 主要依赖 |
| ---: | --- | --- | --- | --- | --- | --- |
| 1 | C-00 | P0 | 固定权威基准的跨平台合同,建立双引擎与回滚合同 | 高 | 1–2 人周 | 现有 OPT-00 |
| 2 | C-01 | P0 | 定义完整数值 IR schema v2 | 高 | 3–5 人周 | C-00、OPT-01/02 |
| 3 | C-02 | P0 | 建立纯数值组件 kernel 合同和能力矩阵 | 中高 | 3–6 人周 | C-01 |
| 4 | C-03 | P0 | 完成 Python 扁平参考执行器与 Shadow 差分 | 中高 | 3–5 人周 | C-01、C-02 |
| 5 | C-04 | P1 | 稳定 C ABI、构建链和隔离 worker | 高 | 3–5 人周 | C-01,可并行 |
| 6 | C-05 | P1 | 完成一个闭环代表子系统的 C 纵向切片 | 高 | 2–4 人周 | C-03、C-04 |
| 7 | C-06 | P1 | 扩展到完整内置 RHS、stream 和物性 | 中高 | 5–9 人周 | C-05、OPT-04 |
| 8 | C-07 | P1 | 稀疏解析/半解析 Jacobian 与局部回退 | 中 | 6–12 人周 | C-02、C-06、OPT-03 |
| 9 | C-08 | P1 | LSTP/MECMAS 事件、模式和默认灰度 | 中 | 4–8 人周 | C-06、C-07、OPT-05/06 |
| 10 | C-09 | P2 | 模型专用 C 代码生成和编译缓存 | 中 | 4–8 人周 | C-06~C-08 |
| 11 | C-10 | P2 | 评估 CVODE;有真实 DAE 需求时再评估 IDA | 中/当前低 | 4–10 人周 | C-06~C-08 |
| 12 | C-11 | P2 | 输出、部署、并发和发布收口 | 中高 | 3–6 人周 | 原生默认候选 |
### 4.1 P0:先固定语义和参考答案
#### C-00 固定权威基准、双引擎和回滚合同
工作内容:
- [x] 固定 `test-mql-8.xml/json`、历史输入和回归证据的跨平台 `LF` 字节合同,实际重新规范化当前 Windows 工作树,并用 Git EOL 状态、原始 SHA-256 和双平台 CI 保护该合同;未重建语义未变的 golden。
- 重新加载 manifest 并执行 Python `0.01 s` smoke;只有发现规范化后的内容或物理结果确实变化时,才重新生成结构清单和 golden。
- 冻结当前 Python 引擎为参考实现,定义 `python/native/shadow/auto` 的启用条件。
- 原生不支持的模型只允许在编译或加载阶段整模型回退;正式验收时禁止静默回退。
- 每个阶段使用独立小提交,记录输入哈希、环境、二进制哈希和回滚开关。
完成标准:相同输入能够稳定强制走 Python;关闭原生后行为与当前参考提交一致;模型、环境和结果来源都能追溯。
#### C-01 完整数值 IR schema v2
完整 IR 必须描述共享数值阶段,以及 RHS、Event、Jacobian 和输出四类独立的按需入口。它们可以复用同一套槽位和依赖信息,但不能被误实现成“每次 RHS 都顺序计算 Event、Jacobian 和输出”。共享 primal 计划为:
```text
state scatter
→ signal
→ mechanical equivalence / dynamic volume
→ property bundle
→ first pressure-flow closure
→ stream SCC/DAG
→ temperature reference update
→ sensitive-island closure / thermofluid fixed point
→ mechanical acceleration
```
四类入口分别编译自己的依赖切片:
```text
eval_rhs = required primal stages → derivative
eval_events = required primal stages → event values
eval_jacobian = RHS primal / local derivative or JVP → CSR values
eval_outputs = required primal stages → output projection
```
IR 至少描述:
- 连续状态、代数坐标、参数、常量、离散模式和工作区槽位;
- 每个阶段和 opcode 的读写集合、单位、缩放、上下界和错误来源;
- stream 图、SCC、物性状态包、外层固定点和事务恢复集合;
- event ID、左/右状态、reset、Jacobian 失效和模式计划;
- 固定的 CSR Jacobian 结构和 output projection;
- IR schema、native ABI、组件模型/实现、介质、编译器、dtype、平台和构建选项组成的结构签名。
原生兼容 program 中不得存在 Python callback。schema v1 保留为参考适配器,不直接扩展成生产 ABI。
完成标准:同一模型重复编译得到字节级稳定的结构和签名;所有索引及读写集合可静态校验;缺少能力时明确拒绝编译。
#### C-02 纯数值组件合同
每个内置组件需声明:
- `kernel_id` 和实现版本;
- 输入、输出、参数、状态和 mode 槽位;
- primal、局部导数/JVP、event 和 reset 能力;
- 支持的正反流、接触和物性范围;
- 类型化错误码和局部数值差分回退边界。
组件 kernel 必须满足“相同输入得到相同输出”,不能依赖隐藏的 Python 对象写入。旧组件类继续作为参考实现。
首批纵向切片建议选择一条完整的 `MECMAS21 → PNRP17 → PNCH012 → PNL0001/LSTP00A` 支路及其介质/stream 闭合,因为它同时覆盖机械、气动、物性、流量和高刚度接触。
#### C-03 Python 扁平参考执行器
先用 Python/NumPy 完整执行 schema v2,目标是验证语义,而不是追求最终速度。它应摆脱 `PortState` 作为主语义载体,能在每个阶段与当前对象引擎比较首个差异。
完成标准:代表模型在普通点、反向流点、事件左右和 Jacobian 扰动点逐槽一致;`0.01/0.2/0.81/2.10 s` 的状态、事件、守恒和物理投影满足现有合同。
### 4.2 P1:形成真正有价值的 C 后端
#### C-04 稳定 C ABI、构建链和隔离 worker
底层使用稳定纯 C ABI,Python 绑定保持很薄。最小接口应包含:
```text
model_create
eval_rhs
eval_jacobian
eval_events
apply_event
eval_outputs
model_destroy
```
接口使用不透明 `ModelContext`、固定宽度整数、连续 `float64` 数组和显式长度;CSR 结构在编译时固定,运行时只填 values。每个任务独占可变 context/workspace/cache/mode,不使用可变全局缓存,热路径预分配且不进行常规堆分配。
首阶段仍由 SciPy BDF 积分,只替换整个 RHS/Jacobian 计算。构建链需覆盖 Windows x64 和 Linux x86_64,固定构建依赖,并禁止 `/fp:fast`、`-ffast-math`、`-march=native` 等会改变事件边界或破坏可移植性的默认选项。
原生访问违规、崩溃或死循环不能在同一 Python 进程中安全恢复。因此原生默认启用前,仿真必须运行在可硬终止的隔离 worker 中;C 同时接收取消标志并在组件阶段、stream SCC、Jacobian 和迭代处有界检查。
#### C-05 闭环纵向切片和首次 Go/No-Go
一次跨语言调用应完成代表子系统的整次 RHS,禁止按组件往返。先为该闭环子系统建立独立代表 fixture,执行 Shadow 和短时积分,不立即扩大组件覆盖。由于此阶段尚未覆盖主模型的全部组件,`test-mql-8` 整模型按能力合同回退 Python 是预期行为,不能拿它衡量 C-05 的端到端收益。
继续扩面的最低门槛:
- RHS 微基准至少达到 Python 的 `2×`;目标值为 `3×` 以上;
- 独立代表 fixture 的短时完整仿真中位时间至少降低 25%;
- 代表 fixture 的状态、事件、模式和守恒门全部通过;存在对应 AMESim 投影时也必须通过;
- `nfev/njev/nlu` 和接受步等工作量没有无法解释的变化;
- 无 Python callback、无静默回退、无内存错误。
如果 RHS 已快很多而完整仿真几乎不变,应先检查 Jacobian、微步数和跨语言边界,而不是直接继续扩大 C 代码。
#### C-06 完整内置 RHS、stream 和物性
- 把所有受支持的内置组件纳入能力矩阵;一个组件不支持时整模型明确走 Python。
- 将 stream 图编译成 SCC 和缩点 DAG:无环部分一次传播,只在循环 SCC 中迭代。
- 将 `p/T/rho/u/h` 及其导数组成同一物性状态包,按精确输入和阶段统一复用。
- 移除每轮临时字典/列表,使用预分配数组和原地误差统计。
- 保留试探状态事务回滚、反向流、温度参考更新和类型化可恢复失败语义。
完成标准:主目标使用的全部内置组件可原生执行;stream 迭代、压力流量残差和物性结果不恶化;跨平台字节合同修复后的同一权威输入通过短程与中程回归,且 `0.2 s` 完整仿真中位时间至少降低 30%。
#### C-07 稀疏解析/半解析 Jacobian
这是获得大幅端到端收益的关键步骤。当前历史报告中,有限差分 Jacobian 会贡献大量额外 RHS 调用;仅把 primal RHS 改成 C 仍会重复执行它。
- 组件局部导数沿完整 IR 传播,直接填充固定 CSR values。
- 不支持或非光滑点只对局部组件、状态列或 SCC 做数值差分,不能让一个局部问题恢复全网有限差分。
- 每次构建记录解析列、局部差分列、回退位置、JVP 审计和装配时间。
- LSTP 接触、流向切换、饱和及临界流动使用分段导数,并在边界上明确选择事件分段或局部回退。
完成标准:有限差分附加 RHS 至少减少 50%,相对完整 C RHS 阶段再降低至少 20% 总时间;事件顺序、模式和物理投影不变。
#### C-08 事件、模式和默认灰度
- 将 LSTP/MECMAS 的 event、mode、reset、Jacobian 失效和 solver restart 纳入正式 IR/C 合同。
- 对接触进入、保持、释放,上下端挡、回弹、同时事件、擦边事件和防抖分别测试。
- 按 `shadow → 显式 native → 模型签名白名单 → auto` 逐级启用。
- 只有当前权威 `10 s` 连续三次通过后,才允许受支持模型默认走 C。
需要特别注意:C-08 不只是把同一接触公式写成 C,还要减少因接触表达方式造成的不必要纳秒级微步。任何软化、容差或事件改动都属于数值算法变化,必须与纯执行优化分开提交和验收。
### 4.3 P2:证明 P1 有效后再做
#### C-09 模型专用 C 代码生成
通用 C IR 解释器稳定后,若 opcode 分派仍是明确热点,再为固定模型生成专用 C。XML 文本不得直接拼入源码;生成器只消费校验后的数值 IR。编译缓存键必须包含完整结构签名、编译器和 flags。
只有相对通用 C 执行器额外获得至少约 `1.5×` 的稳定收益,并且编译成本能在重复运行中摊销时才继续。否则保留通用执行器,放弃这一层复杂度。
#### C-10 原生积分器
- 当前系统是“RHS 内完成代数闭合”的半显式 ODE,先评估 CVODE,不直接切换为 DASSL/IDA。
- 只有 C RHS、Jacobian、事件、可恢复缩步、partial result、activity 和 cancel 合同稳定后,才对 CVODE 做独立 A/B。
- CVODE 在相同正确性门下额外降低至少 25% 总时间才值得替换 SciPy。
- IDA/DASSL 需要新的 `F(t,y,ydot)=0`、代数变量布局、一致初始化、指数和质量矩阵合同。只有真实模型证明无法稳健因果化或 ODE 化时再立项,当前不把它视为提速开关。
#### C-11 输出、部署和发布收口
吸收按需结果变量、列式输出、结果分块、编译缓存、并发限流、worker 硬终止和原生崩溃恢复。确保 native 崩溃只影响当前任务,服务仍能接收新任务。
## 5. 分阶段验证与 Go/No-Go 门
| 验证阶段 | 主要内容 | 通过条件 | 未通过时的处理 |
| --- | --- | --- | --- |
| V0 | fixture、环境、Python 和 AMESim 基线 | 来源一致;Python 连续 3 次稳定;现有测试通过 | 基线仍漂移时停止 C 默认路径开发 |
| V1 | 完整 Python IR | 槽位、阶段、读写集合、事件和回滚差分通过 | 先修隐藏副作用和 IR 合同 |
| V2 | C 纵向切片 | 单组件/逐阶段通过;RHS 至少 `2×` | 未达门槛则检查 IR/FFI,必要时停止扩面 |
| V3 | 完整 C RHS + SciPy | `0.01/0.2 s` 通过;总时间至少降低 30%;工作量变化不超过可解释范围 | 不作为纯执行优化合入默认路径 |
| V4 | C Jacobian/Event | FD 附加 RHS 至少减少 50%;相对 V3 再降低 20%;事件/模式一致 | 保留局部差分,禁止整网静默回退 |
| V5 | 长时与鲁棒性 | `1/5/10 s` 递进;10 s 连续 3 次;无泄漏、无静默回退 | 任一短时前驱失败即停止后续长跑 |
| V6 | 默认启用 | Windows/Linux 发布通过;支持模型走 C,不支持模型明确走 Python;可一键关闭 | 保持显式 opt-in |
正式性能比较统一要求:预热 1 次、完整仿真至少测量 3 次并比较中位数、固定机器与电源模式,冷编译和热缓存分开报告。至少记录:
- 总时间、积分、RHS、闭合、stream、物性、Jacobian 和输出时间;
- `nfev/njev/nlu`、接受/拒绝步、solver 启动和事件次数;
- Jacobian 附加 RHS、局部数值差分和回退原因;
- Python/C 边界调用次数和耗时;
- 峰值 RSS、C 工作区、IR/ABI 版本、编译器、flags 和二进制哈希。
附加硬门:关闭 C 时 Python 路径额外开销不超过 2%;纯执行层替换若使 `nfev/njev/nlu` 或接受步数变化超过 2%,必须按数值语义变化单独调查,不能直接归为性能优化;峰值 RSS 不超过 Python 基线的 110%;10 s 中 C 固定工作区不随步数增长,只有结果数组可随采样数增长。
## 6. 正确性验证范围
### 6.1 Shadow 阶段观察点
两个引擎必须接收完全相同的 `time/state/mode/parameters`,按第 4.1 节的 RHS 阶段逐项比较。差异报告至少包含:阶段、槽位、组件、输入、Python 值、C 值、绝对/相对误差和当前模式。
- 槽位布局、模式码、事件 ID、执行顺序和迭代次数要求一致。
- 连续量使用“每种物理量绝对容差 + 相对容差”,不能用一个绝对容差覆盖压力、流量、温度和位移。
- NaN/Inf、压力越界、符号错误和模式不同立即失败。
- 压力流量最大缩放残差继续使用现有 `1e-7` 门。
- 事件点分别比较左极限、事件处理结果和右极限,不扩大容差掩盖不连续。
差分语料至少覆盖:初始状态、接受状态、Jacobian 扰动状态、固定随机种子扰动、压力近似相等、流量换向、接触启停、端挡释放、物性边界,以及历史 `0.69/0.81/2.05 s` 区域。
### 6.2 AMESim 物理门
Python golden 只用于发现实现漂移,AMESim 结果仍是外部物理基线。正式默认前,应在现有 physical-state-v2.1 基础上至少覆盖:
- 代表性气室压力、温度和质量;
- PNL 管路压力、流量及换向;
- PNRP 活塞力;
- MECMAS 位移、速度和模式;
- LSTP 间隙、接触力和接触切换;
- 每个主要支路至少一个代表量。
检查点至少包含 `0`、`0.04 s` 左右、`0.2`、`0.8 s` 左右、`1`、`2`、`5` 和 `10 s`。AMESim 未保存的内部守恒量继续执行独立绝对残差门。
### 6.3 错误、取消和并发
必须覆盖 C 返回非有限值、物性域错误、闭合不收敛、未知 opcode、ABI 不匹配、损坏二进制、失败后事务回滚、编译失败、并发 context 隔离、单任务取消、重复创建/销毁无内存增长和 native 崩溃后的服务存活。
回退分为三层:
1. **启动前自动选择**:编译或加载时发现不支持能力,整次任务在开始积分前明确选择 Python;这是正式模式唯一允许的自动换引擎路径。
2. **受控运行时错误**:native 已开始积分后返回错误时,当前 native 任务直接失败并保留诊断。灰度验证可以从不可变原始请求的 `tStart` 另起一个 Python 重放任务,但必须标为独立重放,不能算作 native 成功。没有完整保存 BDF 历史、事件上下文和已有输出前,禁止从某个 `(t,y,mode)` 假装无缝续跑。
3. **进程级故障**:访问违规或无法返回时,由父进程硬终止 worker,禁止在受损进程中继续;如需 Python 对照,同样从原始请求重新执行。
正式 C 验收要求异常 `fallbackCount=0`。声明过的局部 Jacobian 数值差分不是异常回退,但必须单独计数。
## 7. 对现有优化清单的取舍
现有优化清单不能整体放弃,应按新路线重组:
| 现有任务 | 决策 | 在新路线中的位置 |
| --- | --- | --- |
| OPT-00 基线与回归 | 保留并加强 | C-00 和全部发布门 |
| OPT-01 因果代数内核 | 冻结成果 | 作为 IR 编译输入和 Python 回退,不再继续零散微调 |
| OPT-02 扁平 IR | 升为主线 | C-01、C-03、C-05 |
| OPT-03 Jacobian | 升为主线 | C-07 |
| OPT-04 stream/物性 | 升为主线 | C-06 |
| OPT-05 步长、接触和鲁棒性 | 必须保留 | C-08;C 不能消除微步根因 |
| OPT-06 dense output | 部分前置、其余后置 | 事件合同进入 C-01/C-08,插值微调后置 |
| OPT-07 输出与内存 | 暂缓但不删除 | C-11 |
| OPT-08 取消与并发 | 必须保留并提前 | C-04/C-11;原生代码更需要进程隔离 |
| OPT-09 10 s 验收 | 必须保留 | V5/V6 发布门 |
| OPT-10 高指数 DAE | 有条件暂缓 | 只有真实 DAE 需求时进入 C-10 |
可以停止继续投入的方向:
- 在现有对象热路径上继续做零散字典和属性访问微优化;
- 继续叠加缺少统一失效合同的通用缓存;
- 优化目标模型中未触发的 `least_squares` 回退;
- 不断增加少量特例半解析列,而不先建立通用组件导数合同;
- 直接对当前对象图使用 Numba;
- 通过修改容差、软化物理或寻找“幸运 maxStep”伪装性能收益。
Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为长期生产依赖。若完整数组 RHS 在 Numba 原型中仍没有明显改善,应先修正 IR 和算法,而不是立即开始大规模 C 重写。
## 8. 可行性评估
| 目标 | 当前判断 | 原因 |
| --- | --- | --- |
| 验证框架、双引擎和基准恢复 | 高 | 现有 runner、golden、AMESim 读取和埋点可复用 |
| 完整 Python 扁平 IR | 中高 | 结构基础已有,主要工作是显式化隐藏副作用和事件语义 |
| 代表子系统通用 C RHS | 高 | 数值边界清楚,一次调用可避免 FFI 碎片化 |
| 当前全部内置组件的 C RHS | 中高 | 约 20 种类型可逐类迁移,但 stream/物性/模式较复杂 |
| 可维护的稀疏解析/半解析 Jacobian | 中 | 收益大,但非光滑接触、流向切换和导数覆盖难度最高 |
| 模型专用 C 代码生成 | 中 | 收益上限高,但构建、缓存、安全和诊断成本明显增加 |
| CVODE 替换 SciPy | 中,且后置 | 可能减少调度开销,但不会自动解决错误方程或接触微步 |
| IDA/DASSL 直接提速 | 当前低 | 目前缺少真正 DAE 的残差、一致初始化和质量矩阵合同 |
| 接近 AMESim 的总速度 | 中低、待实测 | 缺少同机 AMESim 墙钟基线,且当前主要瓶颈同时包含 Jacobian 和接触微步数 |
从项目节奏看,建议把目标分成三档;这里与第 4 节一样使用“人周”,多人并行时日历时间可短于人周总量:
1. **约 15–27 人周:技术决策闭环。** 完成 C-00~C-05,回答完整 IR 是否正确、C RHS 是否有足够收益。
2. **累计约 20–36 人周:可选原生后端。** 完成 C-06,使主要内置 RHS、stream/物性、双平台构建和受控回退可由用户显式启用。
3. **累计约 30–56 人周:默认候选。** 完成 C-07/C-08、10 s 长时、并发隔离和发布门。
模型专用代码生成和原生积分器属于额外阶段,不应计入首个可用 C 后端的承诺。多人并行可以缩短日历时间,但 IR、组件合同、Jacobian 和事件语义存在强依赖,不能按人数等比例压缩。
## 9. 建议立即开展的第一批工作
第一批只做 P0,不直接开始大规模 C 编码:
1. **已完成:** 固定当前权威输入和回归证据的 `LF` 检出规则,重新规范化 Windows 工作树,并增加 Windows/Linux 字节合同测试;未重建语义未变的 golden。
2. 生成当前模型的组件类型、槽位、阶段、副作用和事件能力矩阵。
3. 将 schema v2 写成独立规范,先冻结 RHS 阶段、错误、事务、事件和结构签名。
4. 建立对象引擎与扁平 IR 的逐阶段 Shadow runner。
5. 用一条完整机械—气动—管路—接触支路完成 Python 参考闭环。
6. 评审通过后,再建立最小 C ABI 和纵向切片。
第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。
@@ -45,6 +45,7 @@ FastAPI 自动生成的 OpenAPI 当前可能显示默认 `info.version=0.1.0`;
| 组件库及分类 | 各库 `library.py` |
| 组件目录 JSON | `build_component_catalog()` 与目录 JSON Schema |
| System XML | v3 XSD、`app/system_xml.py` |
| ReactFlow 参数表达式 | `app/parameter_expression.py`、`frontend/src/parameterExpression.ts` 及相应合同测试 |
| 网络最终连接检查 | `SimulationNetwork.connect()` |
| HTTP 路由和请求模型 | `app/main.py` |
@@ -151,6 +152,13 @@ OpenAPI,但当前多数 JSON 响应仍以 `dict[str, object]` 构造,XML、C
- XML 和求解参数统一使用 SI 基准值;
- 实例 ID 和机器标识必须稳定,显示名称不能代替机器标识。
ReactFlow 工程 JSON 的连续数值参数可保存前端既有的受限算术表达式。
编译或 JSON→XML 时,后端在内存中安全求值,再按 `parameterUnits` 从
显示单位换算为 SI。普通数值及数值字符串仍按已存储的 SI 值解释,避免
二次换算;原表达式不回写工程 JSON。离散选项参数和任意代码不属于该合同。
这是补齐已有工程 JSON v1 前端语义的兼容性修复,不改变 System XML v3:
XML 仍只保存最终 SI 数值。
System XML 校验问题统一包含:
```json
+55
View File
@@ -0,0 +1,55 @@
---
name: system-simulation
description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,运行并监视仿真、导出结果,以及在用户确认计划后对 JSON v1 执行单目标、有界连续 SI 参数优化。适用于检查模型、修复编码或换行、运行仿真、获取结果和优化结果统计量;不用于旧格式迁移、任意语义修复、离散或拓扑优化、多目标优化或网页自动预装。
metadata:
openclaw:
requires:
bins: [python3.12]
---
# 系统仿真
使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。`simulation_skill.py` 处理文件和单次仿真,同一 Skill 内的独立入口 `optimization_skill.py` 处理优化计划与执行。
## 基本边界
- 仅处理 ReactFlow 工程 JSON v1 和 System XML v3。版本缺失、不受支持或模型版本不匹配时,说明问题并停止,不进行迁移猜测。
- 组件参数是仿真前设定的固定输入;结果变量才是可随时间绘制的量。不要把“参数”当成结果曲线。
- 工程 JSON 可在连续数值参数中保存受限算术表达式。检查、编译或生成 XML 时由后端安全求值并换算为 SI;不得把计算结果回写到源 JSON。
- 文件通过格式校验不等于物理系统一定可求解。不要隐瞒编译或运行阶段的诊断。
- 不直接覆盖源文件。普通检查或仿真不自行修改参数、连接、组件类型、模型版本或求解设置;优化也只能在用户确认的派生副本中改变明确选定的参数。
- 优化仅面向 ReactFlow 工程 JSON v1,设计变量必须由用户指定,或由用户明确授权 Skill 提议后再纳入计划;它们必须是连续、线性 SI 参数。现有后端不负责证明参数连续性,不能只因字段是数字就自动选作设计变量。带编辑器、离散选项或后端显式否决的参数必须拒绝,整数、条件显示控制量及会改变活动端口、模式或拓扑的参数不得进入连续优化。
- 本版不支持把模型自动注入网页、生成可直接打开的预装页面、任意损坏文件修复、模型迁移、离散或拓扑优化以及多目标优化。不要用手工网页操作冒充支持。
处理文件、解释格式或选择结果变量时,读取 [references/file-contracts.md](references/file-contracts.md)。请求文件修复时,再读取 [references/repair-policy.md](references/repair-policy.md)。需要运行、监视、取消仿真或交付结果时,读取 [references/workflows.md](references/workflows.md)。用户请求按仿真结果优化参数时,必须读取 [references/optimization-workflow.md](references/optimization-workflow.md)。
## 工作原则
1. 先用 `inspect` 确认输入格式、版本、结构和诊断,再基于检查结果简要解释组件、连接与仿真设置。
2. 如果用户要求修复,只能执行文本规范化。先展示预览和源文件 SHA-256,获得针对该预览的明确确认后,才可写入另一个输出路径;随后重新 `inspect`。
3. 单次仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量:
- 分别查看所选变量;
- 将多个同单位、可比较的变量叠加;
- 将不同物理量或单位的变量上下排列。
4. 用户用显示名称描述组件或变量时,利用检查结果中的稳定 ID、结果 `key`、物理量和单位消歧。存在重名、多个候选或“参数/结果变量”含义不清时,先询问,不能替用户猜。
5. 使用 `simulate` 的事件流持续判断 queued、validating、compiling、integrating 和结束状态。仿真时间暂时不变但内部活动仍增长时,只说明正在处理慢步,不能宣称卡死。
6. 成功运行后交付用户选择的 SVG 曲线和完整 `results.csv`,并简要说明完成状态、实际仿真终点和重要诊断。失败或取消时交付能够安全生成的部分结果;若运行前即失败而没有 CSV,要明确说明原因。
7. 优化需求优先按自然语言理解:从检查结果补齐稳定结果 `key`、单位和当前参数值,未指定的算法、预算、容差和输出目录采用参考文档中的推荐默认值。不要要求用户填写规格 JSON,也不要追问随机种子、变异因子等已有默认值。`plan --present` 成功后,面向用户展示的设计变量当前值和单位必须直接采用 `presentation.designVariables[].current` 与 `unit`;完整审计计划中的对应字段是 `designVariables[].initial` 与 `unit`。不得根据源 JSON 的 `parameterUnits` 再换算或另行推断。
8. 信息足以形成规格后,直接在内部写入规格并执行无候选仿真的 `plan`,无需先征求生成计划的许可。普通流程必须把完整计划以仅当前用户可读的权限保存到输出目录之外的新内部文件,并让 stdout 只返回展示白名单。计划阶段从回复第一个字起使用用户当前语言并直接展示计划,只列目标、可调参数及范围、约束、仿真预算槽位、搜索启动时限、完整输出位置和重要假设。输出位置必须是 `plan` 返回的完整绝对路径,不用 `...` 缩写。采用默认搜索设置且没有需要用户决策的警告时,只说“采用默认搜索设置”及其执行上限,不显示“无警告”或原始 warnings、算法名称或变体、随机种子、种群、变异/交叉参数、搜索/复验预算拆分、边界处理、端点播种或理论完整代数;若展示视图返回 `nonDefaultSettings`,则必须把其中将被确认的非默认值简明列出。参数明显是连续物理标量时,把连续性作为计划假设,一次整体执行确认即可覆盖,不展示用于作出判断的内部合同字段清单;只有语义确有歧义时才自然地追问。内部声明代码、SHA、`planHash` 和 `confirmationToken` 默认不展示。
9. 生成计划不等于获准执行。只有用户看过计划摘要并明确表示开始后才能传入 `--confirmed`;用户说只要计划、先看计划且暂时不要运行或其他同等表述时,展示计划后直接停住,不在本轮追问是否开始。计划任一实质内容变化都要重新确认。
10. 优化中的失败、取消、停滞或不完整仿真不计算目标分数。最终只对预算内找到的最佳可行候选做一次绕过缓存的完整复验;复验通过前不把候选称为已验证方案。
11. 严格区分搜索停止与候选复验:`verified` 只说明最佳搜索候选的新鲜复验通过,不等于搜索收敛、系统达到稳态或全局最优。优化执行完成后的结果报告分别说明搜索候选评估、按阶段拆分的仿真预算槽位占用及完成/失败记录、未形成试验记录的槽位、缓存命中、独立复验、未用预算和真实停止原因;这些审计明细不属于计划摘要,槽位占用也不能说成后端已接收或已完成。只要 `searchConvergenceEstablished` 为 `false`,计划、进度和结果中都不得说搜索“已收敛”“将收敛”或“大概率收敛”;若只想表达重复运行可能得到相同结果,改说“可能再次找到同一候选”。内部防死循环上限及重复停滞后的种群塌缩都不得解释成收敛。
12. 目标使用 `final` 时必须报告脚本给出的末段趋势诊断状态;只有完整且覆盖计划终点的新鲜序列才能分析趋势,诊断不可用或样本不足时明确说明且不自行推断。检测到明显变化时,说明终点值只是快照。有限样本只能表述为“在这些已评估点中,参数增大时结果均增大或均减小”,不得称整个范围单调,也不得外推样本之间或未采样位置。新鲜复验已经通过后不再建议重复同一复验;最佳点落在边界时,未经物理、安全和组件合同方面的工程可行性确认,不得建议放宽边界或把它列作默认下一步。
脚本命令统一从仓库根目录运行:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py --help
py -3.12 skills/system-simulation/scripts/optimization_skill.py --help
```
在 OpenClaw 中不要假设当前目录是仓库根目录。使用 `"{baseDir}/scripts/simulation_skill.py"` 或 `"{baseDir}/scripts/optimization_skill.py"`,也可以先进入本 Skill 目录再从 `scripts/` 运行;不要把 `{baseDir}` 当成需要手工猜测的仓库路径。
Windows 优先使用仓库 `.venv-win\Scripts\python.exe`(若存在),否则使用 `py -3.12`;Linux 优先使用 `.venv/bin/python`,否则使用 `python3.12`。不要调用未经版本确认的 `python`,本项目要求 Python 3.12。
优先依赖脚本返回的结构化 JSON/JSONL、稳定错误码和退出码做判断,不解析中文提示文本来驱动下一步。
@@ -0,0 +1,7 @@
interface:
display_name: "系统仿真与参数优化助手"
short_description: "用自然语言检查、仿真模型,并按安全默认值规划连续参数优化"
default_prompt: "使用 $system-simulation 按我描述的目标优化模型参数;请自动补齐安全默认值,先给出易读计划,等我一次确认后执行。"
policy:
allow_implicit_invocation: true
@@ -0,0 +1,99 @@
# 文件合同与解释规则
## 支持范围
本 Skill 只接受以下两种当前格式:
| 格式 | 版本标志 | 用途 |
| --- | --- | --- |
| ReactFlow 工程 JSON | 顶层 `projectSchemaVersion: 1` | 保存组件、画布、端口显示快照、连线和仿真设置,适合继续编辑 |
| System XML | 根元素 `System/@schemaVersion="3"` 且 `unitSystem="SI"` | 保存可执行模型,适合校验、编译和求解 |
默认让 `inspect --format auto` 根据内容和扩展名识别格式。若内容与扩展名不一致、无法唯一识别或用户明确指定格式,则报告实际证据,不悄悄按另一种格式解释。
System XML v1/v2、缺少 `projectSchemaVersion` 的旧工程、字符串端口和不匹配的组件 `modelVersion` 均不属于本 Skill 的迁移范围。不能只改版本号使其看似当前格式。
## 工程 JSON v1
顶层合同为:
```text
projectSchemaVersion = 1
name
nodes[]
edges[]
simulation { t_start, t_stop, step, max_step, method }
```
重要规则:
- 节点的 `id` 是实例稳定标识;显示标签不能替代它。
- `data.modelType` 标识注册模型,`data.modelVersion` 必须与当前组件目录精确匹配,执行前不得自动补成当前版本。
- `data.parameters` 保存输入值;`parameterUnits`、科学计数法偏好、坐标、旋转和镜像属于编辑显示信息。
- 连续数值参数可保存受限算术表达式字符串。支持可选前导 `=`、`+ - * / ^ **`、括号、科学计数法、`pi/e` 和白名单函数 `sqrt/abs/sin/cos/tan/asin/acos/atan/exp/ln/log/log10/min/max/pow`。不支持变量引用、组件间引用、属性访问或任意代码。
- 普通数值及可直接解析的数值字符串按已存储的 SI 值处理;只有表达式的计算结果才按 `parameterUnits` 中的显示单位换算为 SI。例如 `area0 = "3.14*10**2/4"` 且单位为 `mm2` 时,XML 值为 `7.85e-05` m²,JSON 仍保留原表达式。
- 下拉选项、介质引用等离散参数不允许使用表达式。表达式语法、值域或复杂度不合法时,必须在编译/仿真前明确报错,不得猜测或改写。
- 连接必须保留两端组件及 Handle。不能根据节点位置猜测缺失端口。
- `simulation.step` 是结果采样间隔;`max_step` 是求解器内部步长上限,两者不能混用。
工程 JSON 可以导出为 System XML v3,但转换后不会保留全部画布显示信息的对等逆转换合同。
导出时表达式仅在内存中求值,System XML 只写入换算后的 SI 数值,不改动输入工程对象或源 JSON 文件。
## System XML v3
XML v3 只描述“求解什么”:
- 每个 `Component` 必须有唯一 `id`、注册 `type`、精确 `modelVersion` 和完整 SI 参数;
- 每条连接由两个 `Endpoint(component, port)` 组成;端口类型、方向和物理合同由后端注册表恢复;
- `Simulation/@sampleStep` 对应工程 JSON 的 `simulation.step`;
- 不保存组件位置、旋转、镜像、显示单位或端口显示快照;
- 当前后端固定按 v3 校验,不会根据文件内容选择旧解析器。
XML 校验依次覆盖安全/语法、XSD 和语义层。通过这些检查后,编译和求解仍可能发现未连接端口、缺少储能锚点、方程结构或数值问题。
## 简要解释模型
解释必须依据 `inspect` 的结构化输出以及组件目录,而不是仅凭组件名称推测。优先说明:
1. 文件格式、版本和项目名;
2. 仿真起止时间、采样间隔、最大内部步长和算法;
3. 组件数量、稳定 ID、模型类型和主要输入参数;
4. 连接数量、连接端点及能够确定的物理域;
5. 错误、警告,以及它们属于格式、语义、编译还是运行阶段。
保持“文件合同正确”和“物理模型合理”两个结论分开。没有组件文档或注册元数据支持时,不声称某个参数具有推测出的物理效果。
## 参数与结果变量
必须明确区分:
- **参数**:仿真开始前设定的固定输入,例如质量、初始压力、摩擦选项;通常没有时间序列。
- **结果变量**:仿真返回的时间序列,例如位移、速度、压力或流量;只有这类量可以选作曲线。
选择曲线时以结果元数据为准,至少核对:
```text
key + componentId + componentType + label/quantity + unit
```
稳定 `key` 是传给 `simulate --variables` 的最终标识。用户只说“质量块的速度”而存在多个质量块,或一个组件存在多个符合描述的速度结果时,列出候选的组件 ID、结果名称和单位,请用户消歧。
`inspect` 默认对组件摘要、连接和结果变量分页。先读取 `componentTypes` 了解完整模型的组件类型分布,再根据 `componentPage`、`connectionPage` 或 `resultVariablePage` 的 `nextOffset` 翻页。优先使用 `--variable-query` 按组件 ID、标签、物理量或单位缩小范围;只有用户点名组件时才使用 `--component` 读取该组件的完整源数据和可用的编译合同。
组件摘要中的 `compiledForSimulation` 表示该节点是否进入动态求解网络。介质/物性配置节点仍属于工程,因此会保留在组件总数和列表中,但通常标记为 `false`;这不表示组件丢失或编译失败。
曲线模式约束:
- `separate`:每个所选结果变量分别成图;
- `overlay`:只叠加单位相同且含义可比较的结果变量;
- `stacked`:不同物理量或不同单位上下排列,避免共用一个纵轴造成误读。
本版运行 `simulate` 时必须指定至少一个 `--variables` 稳定键,避免在大型模型上无意生成成百上千张曲线。完整 CSV 仍包含全部可用结果变量。
## 权威来源
- 工程 JSON 请求合同:`app/main.py` 中的 `ReactFlowProjectPayload`
- 组件目录:`GET /api/components/catalog`
- XML v3:`schemas/system-simulation-v3.xsd`、`docs/standard/system-xml-v3.md`
- 接口边界:`docs/standard/backend-interface-version-spec-v1.md`
- 结果变量:组件注册合同中的 `RESULT_VARIABLES` 及仿真结果元数据
@@ -0,0 +1,335 @@
# 单目标参数优化工作流
## 执行入口与范围
本 Skill 内的 `scripts/optimization_skill.py` 是独立外层优化入口,它复用同目录的 `simulation_skill.py` 访问现有 FastAPI 后端。优化算法、候选生成、目标统计和报告都在 Skill 进程中完成,不要求后端提供原生优化端点或优化参数准入字段。优化入口只接受已通过检查和编译的 ReactFlow 工程 JSON v1,不接受 System XML 作为优化源文件。
支持范围固定为:
- 一个结果变量统计量构成的单目标;
- 1–16 个有限上下界内的线性尺度连续 SI 参数;
- 0–16 个结果响应约束;
- 最多占用 200 个仿真预算槽位;
- 顺序执行的有界 DE/rand/1/bin 差分进化。
不支持离散、整数、介质引用、条件显示控制量、活动端口数、组件类型、连接、拓扑、无界、对数尺度或多目标优化。不执行用户文本中的任意 Python 回调或表达式,也不在优化中改变仿真时段、采样间隔、最大内部步长、求解方法或物理拓扑。用户给出多个愿望时,必须选定一个目标,再将可用上下限表达的其余要求定义为响应约束;不自行设计权重合成多目标。
## 自然语言交互与推荐默认值
用户不需要了解优化规格 JSON。通常只需说明:想改善哪个结果、让它变大/变小或接近什么值,以及允许调整哪个参数和范围。若用户明确说“你先假定一个”或同等授权,可以提出一个设计变量和工程范围,但必须标成待确认的假设,不能伪装成后端验证结论。
先运行 `inspect` 和必要的编译检查,再把用户说法解析为严格规格:
- “结束时”映射为 `final`;“整个仿真中的最大/最小值”映射为 `maximum` / `minimum`;未指定窗口时使用完整结果时间范围。
- 从结果和参数合同补齐稳定 ID、`resultKey`、SI 单位和当前值;能唯一解析时不要反问用户这些机器字段。
- 用户未提响应约束时使用空列表,不逐项询问“是否需要约束”。
- 目标、参数、范围或方向无法唯一确定时必须询问;不要为了可默认的技术字段打断用户。
用户未指定高级设置时,把下列推荐配置显式写入规格文件,保证计划可复现:
```json
{
"algorithm": {
"name": "differentialEvolution",
"seed": 0,
"populationSize": 8,
"mutationFactor": 0.8,
"crossoverProbability": 0.7
},
"budget": {
"maxSimulationRuns": 25,
"maxWallSeconds": 3600
},
"validation": {
"relativeTolerance": 1e-08,
"absoluteTolerance": 0.0
}
}
```
用户描述了响应约束但未指定容差时,默认 `tolerance = 0`;未指定 `scale` 时,取该约束所有非空边界绝对值的最大值,若结果为零则取 `1`。目标或约束接近零、后端存在可观察的不确定性,或用户给出安全裕量时,应提出有物理意义的容差建议,不能用一个跨量纲的非零绝对容差替代判断。
默认输出目录使用项目文件所在目录下尚不存在的 `optimization-runs/<项目名>-<UTC时间戳>`。源目录不可写时,改用当前可写工作区中的同名新目录,并在计划摘要中说明实际位置。不要让用户命名目录,也不要覆盖既有目录。默认计划可对用户概括为“使用默认搜索设置,最多占用 25 个仿真预算槽位;搜索启动时限为 1 小时,并为找到的最佳可行候选预留一次独立复验”。搜索启动时限到达后不会取消正在运行的健康仿真,预留复验也可能在其后执行,所以不能把它称为总耗时硬上限。除非用户询问或计划产生需要用户决策的覆盖不足警告,不主动讲解算法名称或变体、种群、变异因子、交叉概率、随机种子、搜索/复验预算拆分、边界处理、端点播种、理论完整代数或公式。
生成新计划不以历史运行作为前置检查;除非用户要求复用、比较或解释旧结果,不主动扫描、校验或汇总旧优化目录。若当前对话已经明确存在源文件与实质规格相同的历史运行,为避免混淆最多用一句话注明它只是历史参考、不属于本次计划;用户未追问时不展开旧候选、停止细节、趋势或复验数据,也不用历史样本预测新搜索会收敛或断言连续区间性质。
## 优化规格 JSON
规格是独立 JSON 文件,顶层必须且只能包含 `optimizationSchemaVersion`、`objective`、`designVariables`、`constraints`、`algorithm`、`budget` 和 `validation`。完整示例如下;其中组件 ID、参数名、结果 `key` 和单位必须换成实际 `plan` 所检查的合同:
```json
{
"optimizationSchemaVersion": 1,
"objective": {
"resultKey": "pressure.chamber_1.absolute",
"expectedUnit": "Pa",
"statistic": {
"kind": "maximum",
"window": {"start": 0.0, "end": 1.0}
},
"goal": {"kind": "minimize"}
},
"designVariables": [
{
"id": "orifice_area",
"componentId": "valve_1",
"parameter": "area0",
"unit": "m2",
"lower": 1e-06,
"upper": 0.0001
}
],
"constraints": [
{
"id": "mass_flow_limit",
"resultKey": "massFlow.valve_1.port_2.intoComponent",
"expectedUnit": "kg/s",
"statistic": {"kind": "peakAbsolute", "window": null},
"lower": null,
"upper": 0.25,
"tolerance": 0.0,
"scale": 0.25
}
],
"algorithm": {
"name": "differentialEvolution",
"seed": 0,
"populationSize": 8,
"mutationFactor": 0.8,
"crossoverProbability": 0.7
},
"budget": {
"maxSimulationRuns": 25,
"maxWallSeconds": 3600
},
"validation": {
"relativeTolerance": 1e-08,
"absoluteTolerance": 0.0
}
}
```
规格使用严格字段集。未知字段、缺失字段、重复键、布尔型伪装的数字、`NaN` 和无穷值均不能进入优化;`optimizationSchemaVersion` 固定为 `1`。
`plan` 的 `spec.resolved` 会额外显示只读派生字段,例如统计量 `metricUnit`、算法 `strategy`/`workers`/`updating`、搜索策略、边界处理、一维端点播种策略、停滞代数、预算预留量和固定复验次数;这些字段不是输入规格字段,不能复制回 schema v1 规格文件。输入优化规格仍是 schema v1;最终结果的 `optimizationResultSchemaVersion` 为 `2`,两者不要混淆。
### 目标
`objective.resultKey` 必须是编译模型声明的稳定结果键,`expectedUnit` 必须与结果元数据单位完全一致,无量纲时使用空字符串。`goal.kind` 可为:
- `minimize`:最小化统计值;
- `maximize`:最大化统计值;
- `target`:最小化 `abs(statistic - value)`,此时必须增加有限数字 `goal.value`。
`minimize` 和 `maximize` 不接受 `goal.value`。`target` 只定义目标损失,不是提前停止阈值。
`expectedUnit` 是原始时间序列单位。`final`、`minimum`、`maximum`、`timeMean`、`rms` 和 `peakAbsolute` 的统计量单位与它相同;`integral` 与 `absoluteIntegral` 的统计量单位为原单位乘秒(结构化输出的 `metricUnit` 使用 `(<seriesUnit>)*s`,无量纲序列积分为 `s`)。`target` 的 `value` 使用统计量单位。
### 设计变量
`designVariables` 必须包含 1–16 项,ID 和 `(componentId, parameter)` 均不能重复。每项必须:
- 由用户明确指定,或在用户授权 Skill 提议后纳入计划;执行时它必须是连续、线性 SI 参数,且改变它不会切换模式、改变活动端口或拓扑;
- 不带组件合同中的 `editor` 或离散 `options`;若未来合同显式提供 `optimizationEligible: false`,该否决不可由用户确认覆盖;
- `unit` 与参数合同的 SI 单位完全一致;
- 使用有限数字 `lower < upper`,同时满足目录最小值、最大值和排他下界;
- 使源模型编译后的当前 SI 值位于闭区间 `[lower, upper]` 内。
当前后端目录没有能够单独证明“连续量、整数、模式控制量、活动端口数”的机器字段;字段缺失本身不是连续性证据。不得仅凭“值是数字”自动挑选设计变量。若用户已选定参数,且参数合同、物理量与单位、组件语义和用户给出的连续区间一致表明它是普通物理标量,同时不存在 `editor`、`options` 或显式否决,则可把“按连续线性 SI 参数处理且不改变结构”写成计划假设,不必在生成计划前要求用户复述声明。用户对该计划的一次整体执行确认同时接受这项假设。
若参数像整数、计数、无量纲模式量、条件控制量,元数据彼此矛盾,或无法判断改变它是否影响端口和拓扑,必须先用自然语言询问,例如:“这个参数可以取任意小数,并且调整时不会切换组件模式或端口吗?”不要向用户显示内部声明代码。`editor`、`options` 和未来可能出现的显式否决只用于拒绝明显不适用的参数,不能证明其余参数连续。
参数值、初值和边界一律使用线性 SI 合同。ReactFlow JSON 中普通数值参数已经是 SI 值,`parameterUnits` 只是编辑器显示信息,不能据此把普通数值再次换算;表达式所需的显示单位换算由后端完成。如果用户用显示单位给出边界,才把用户输入换算为 SI。普通 `plan --present` 成功后,`presentation.designVariables[].current` 和 `unit` 是计划摘要中当前值与单位的唯一依据;完整审计计划中的对应字段为 `designVariables[].initial` 和 `unit`。不要从源 JSON 重新计算显示值,若其他信息与它矛盾则先排查而不是向用户展示两套数值。用户给出的边界已经使用该 SI 单位且没有矛盾时,不主动解释 `parameterUnits` 或添加显示单位换算旁注。后端在计划阶段将源 JSON 转换为基准 System XML v3;每个候选都从该 XML 重新生成,只替换选中 `Parameter/@value` 的 SI 数字,不在前一个候选上累积修改,源 JSON 永不被覆盖。
### 统计量与响应约束
`statistic` 必须同时包含 `kind` 和 `window`。支持的 `kind` 为 `final`、`minimum`、`maximum`、`timeMean`、`rms`、`integral`、`absoluteIntegral` 和 `peakAbsolute`。`window` 可为 `null`,表示使用完整返回时间序列;也可为有限数字的 `{start, end}`,且 `start < end`。
窗口边界不在采样点时使用线性插值,不为超出结果范围的窗口外推。时间必须严格递增,时间和数值必须有限且等长。`timeMean`、`rms`、`integral` 和 `absoluteIntegral` 使用梯形时间积分。`minimum`、`maximum` 和 `peakAbsolute` 只是样本及插值边界上的统计,不证明连续时间真实峰值;安全关键峰值可能比 `sampleStep` 更窄时,必须报告采样风险。
`constraints` 只支持响应约束。每项必须包含唯一 `id`、`resultKey`、`expectedUnit`、`statistic`、`lower`、`upper`、`tolerance` 和 `scale`。`lower` 或 `upper` 可为 `null`,但至少一个必须是有限数字;两者都存在时必须 `lower <= upper`。`tolerance >= 0`,`scale > 0`。下界在 `value >= lower - tolerance` 时满足,上界在 `value <= upper + tolerance` 时满足。
`scale` 只用于将违反量归一化为 `rawViolation / scale`,以便对完整但不可行的候选排序;它不改变可行边界,也不是软约束权重。需要等式时用显式容差带,不使用浮点精确相等。
约束的 `lower`、`upper`、`tolerance` 和 `scale` 都使用该约束统计量的单位,而不是一律使用原始序列单位。复验中的单个 `absoluteTolerance` 数值分别按每项统计量自己的单位解释;目标与约束量纲差异很大时优先把它设为 `0` 并使用相对容差,或明确接受这一 schema v1 限制。
## 计划确认
信息足以形成规格后直接执行 `plan`,不需要用户先批准计划生成。它校验源 JSON、规格、结果键/单位、设计变量合同和边界,并请求后端生成基准 XML,但不开始优化候选仿真。完整审计计划文件包含源 JSON 与规格 JSON 的绝对路径/SHA-256、解析后规格、基准 XML SHA-256、目标/约束元数据、解析后设计变量、执行上限、绝对输出目录、`parameterContinuity`、`requiredAssertions`,以及值相同的 `planHash` 与 `confirmationToken`;普通流程的 stdout 只返回严格白名单的展示视图,不包含这些执行凭据和搜索内部字段。
计划中的 `OPTIMIZATION_CONTINUITY_USER_ASSERTION` 是给脚本和审计使用的内部标识,不是要求用户照抄的口令。后端不会验证参数的物理/语义连续性,因此面向用户的计划摘要必须用普通语言列出相关假设。若参数语义清楚,用户在看到摘要后明确同意开始运行,即视为同时接受完整计划和这些假设;未得到这次整体确认时不得传入 `--confirmed`。若参数语义不清,则应在执行确认之前先完成自然语言消歧。
`confirmationToken` 绑定源 JSON SHA-256、规格文件 SHA-256、基准 XML SHA-256、解析后的输出目录、后端 base URL、流读取超时、连续性确认策略和搜索策略。向用户展示计划摘要并获得明确确认后,才能传入 `--confirmed`。`optimize` 还必须提供 `plan` 返回的源 SHA-256、规格 SHA-256 和 token;脚本会重建当前计划并拒绝旧策略或其他过期确认。任一绑定项改变,包括搜索策略升级,都必须重新 `plan` 和确认。输出目录必须是新路径或空目录。
`requiredAssertions[].code`、各类 SHA、`planHash` 和 `confirmationToken` 是代理执行命令时保存和回传的机器字段。普通对话中应由 Skill 内部保管,不向用户倾倒;只有用户主动要求审计细节,或排查计划过期/文件变化时才展示。
普通计划摘要只需要回答:
- 要改善哪个结果,用什么统计口径;
- 调整哪些参数,各自在什么范围;
- 有哪些响应约束;
- 采用默认还是用户指定的搜索配置、最多占用多少仿真预算槽位、停止启动新搜索候选的时限,以及可使总耗时超过该时限的在途仿真和预留复验;
- 哪些参数连续性或工程边界属于假设;
- 输出写到哪里,并明确源模型不变。
计划正文只描述即将执行的运行,不把历史结果回顾、搜索审计明细或对本次结果的预测混入计划。默认设置不存在警告时,“采用默认搜索设置”已足够,不再把内部配置、端点播种或合同判定字段展开成技术清单。若安全展示视图包含 `nonDefaultSettings`,只列出其中实际偏离推荐默认值、并会随本计划一起确认的设置;不要反过来读取完整审计计划扩展技术细节。
用户尚未限制本轮只做计划、且接下来是否执行需要确认时,摘要后只问一次中性的自然问题,例如:“就按这个方案开始吗?”不要主动把换目标、放宽参数边界或其他扩展范围列成备选项。用户明确同意后直接执行,不再追加连续性声明、算法参数或 token 确认。若用户说“只要计划”“暂时不要运行”或同等意思,则交付摘要后直接陈述会停在计划阶段,不在本轮询问是否开始,等用户之后主动要求。用户主动提出调整时再讨论;涉及放宽工程边界时,必须先确认新的范围符合物理、安全和组件合同。
无警告且使用默认设置时,按下列内容边界组织计划回复;可以顺应用户语言调整措辞,但不要增加其他技术段落:
```text
优化目标:让哪个结果按什么统计口径变大、变小或接近目标值。
调整参数:参数名称、脚本 plan 返回的当前 SI 值、用户确认的 SI 范围。
响应约束:列出约束;没有就说无。
运行上限:采用默认搜索设置,最多占用多少仿真预算槽位;搜索到时后不再启动新候选,但会等在途仿真结束,并为找到的最佳可行候选预留一次独立复验。
重要假设:用一句普通语言说明参数按连续物理量处理且不改变模式或结构。
输出:plan 返回的新目录完整绝对路径,源模型不变。
结束语:若本轮可以询问执行,则问“就按这个方案开始吗?”;若用户说暂时不要运行,则说“计划已准备好,我会停在这里,等你之后明确说开始。”
```
正式计划回复从第一个字起使用用户当前语言并直接进入计划内容;不加过程旁白,不显示 `warnings: []` 等内部状态,不复述内部枚举名,也不在计划后追加单位科普、算法原理、历史回顾、结果预测或调整建议。输出目录照抄 `plan` 返回的完整绝对路径,不用省略号或相对路径。只有真实警告、无法消除的单位歧义或其他需要用户决策的问题,才在相应条目中简短说明。
## DE/rand/1/bin 搜索
外层优化由 `optimization_skill.py` 使用 Python 标准库自行实现,不调用 SciPy 优化器,也不安装额外优化依赖。`algorithm` 严格包含:
```text
name = differentialEvolution
seed = 0 .. 2^32-1 的整数
populationSize = 4 .. 50
mutationFactor = (0, 2]
crossoverProbability = [0, 1]
```
`populationSize` 是实际种群个体数,不是乘以设计变量数的倍数。初始候选先在每个线性归一化坐标上做拉丁超立方分层,再用工程当前参数替换第一行,所以替换后的最终种群不承诺保持严格拉丁超立方的每层唯一性。只有一个设计变量时,初始种群还会强制包含归一化坐标 `0` 和 `1`,也就是精确测试用户确认的 SI 下界和上界;若基准点已经等于某个端点,不再为该端点制造重复行。第一次提交仍是基准仿真。基准仿真必须完整成功;基准可以不满足响应约束,此时仍可继续搜索。
每个完整代开始时冻结当前种群和排名;代内所有目标个体都只从这份冻结种群中选三个不同且不是自己的个体,按 `a + mutationFactor * (b - c)` 生成变异向量,接受结果在完整代结束后统一成为下一代。这是 `updating = deferred`,不会让同一代后面的候选使用刚被接受的新个体。越出归一化区间的坐标通过周期为 `2` 的镜像反射折回 `[0, 1]`,不再硬裁剪到端点;随后做 binomial crossover,并强制至少一个坐标来自变异向量。候选排名顺序是:完整可行点按目标损失;其次是完整但不可行点,按约束归一化违反量总和再按目标损失;失败点最后。平局用最早评估 ID 确定性打破。
`seed` 由标准库 `random.Random` 使用。固定种子可使同一 Python 实现和同一后端环境中的候选顺序可重放,但不证明跨 Python 版本、后端代码、操作系统或硬件位级一致。
当前搜索策略标识为 `de-rand-1-bin-deferred-reflection-1d-endpoints-stagnation-v3`。该标识随解析后算法配置进入计划,并绑定到确认 token;不能拿旧搜索策略产生的确认 token 启动新版搜索。
## 预算、停止、缓存与失败
`maxSimulationRuns` 必须至少是 `populationSize + 1`,且不得超过 200。它是仿真预算槽位的严格总上限。每次准备进度记录和打开后端流之前先保守占用一个槽位,因此即使本地进度文件创建或连接失败,该槽位也不会重新使用。搜索最多使用 `maxSimulationRuns - 1` 个槽位,始终为最佳可行候选保留一个绕过缓存的新鲜复验槽位。
计划会给出 `fullGenerationsWithUniqueCandidates = floor((searchRunLimit - populationSize) / populationSize)`。它表示在候选都不重复时,初始种群之外预算还能完整覆盖多少代。值为 `0` 时会返回 `OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY` 警告;此时仍可按用户确认运行,但必须明确说明搜索覆盖很弱。若希望至少完整执行两代,预算至少应为 `3 * populationSize + 1`。
`maxWallSeconds` 必须在 10 秒至 7 天之间。它只在启动下一个搜索候选前检查:达到后不再启动新搜索,不取消正在处理慢步的健康仿真。已存在最佳可行候选时,即使搜索墙钟已到,预留的一次新鲜复验仍会运行。
搜索没有目标阈值、目标收敛容差或局部抛光阶段。每个完整 DE 代结束后会统计该代是否产生过新的后端仿真提交;连续 `3` 个完整代没有新提交时提前停止:
- 若当前种群映射为同一个精确 SI 候选,停止原因为 `populationCollapsedAfterDuplicateStagnation`。这只表示重复停滞发生时种群已经塌缩为一个精确候选,不是数值收敛判定,也不证明全局最优;
- 若种群仍映射为多个精确候选,停止原因为 `duplicateProposalStagnation`。这表示候选生成持续重复缓存中的点,不是收敛判定。
仿真预算用完时为 `simulationBudgetExhausted`,搜索墙钟到达时为 `searchWallTimeReached`。为防止任何未预见的重复循环,候选请求另有 `max(100, searchRunLimit * 20)` 的内部防死循环上限;`optimizerCallLimitReached` 仅表示该安全保护触发,绝不能解释成搜索已经收敛。用户中断或结构化错误也会中止运行。
结果中的 `generations` 只统计完整完成的 DE 代;如果预算、墙钟或候选请求保护在一代中途阻止下一个候选,该部分代不会增加计数,也不会产生 `optimization-generation-completed` 事件。完整代进一步分成 `generationsWithNewBackendSubmissions` 和 `generationsWithoutNewBackendSubmissions`;初始种群不算一个 DE 代。“候选都唯一时预算可覆盖的完整代数”只是计划容量,不得当作实际完成或有效搜索代数。
缓存仅在当前 `optimize` 进程内有效。它根据固定参数 ID 顺序和映射后的精确 SI 数值识别重复候选;目标和所有响应约束共享一次仿真。缓存命中不增加后端提交数,最终复验始终绕过缓存。
最终结果使用 schema v2 分开记录搜索、复验、缓存和预算:
- 顶层 `search` 给出停止原因及类别、恒为 `false` 的 `searchConvergenceEstablished`、`populationCollapsedToSingleCandidate`、搜索候选评估数、`submissionSlotsConsumed`、缓存命中率、搜索预算的上限/已用/未用/是否耗尽、完整代及有新提交/无新提交代数、停滞连续代数、最终种群精确候选数和防死循环上限;
- 顶层 `verification` 给出独立复验是否通过、源文件是否未变、比较容差和 `submissionSlotsConsumed`,并明确其含义是候选可复现且可行,而不是搜索收敛;
- `counts.searchProposals`、`searchSubmissionSlotsConsumed`、`verificationSubmissionSlotsConsumed`、`backendSubmissionSlotsConsumed`、分阶段完成/失败记录、`unrecordedSubmissionSlotsConsumed`、`cacheHits` 和 `remainingSearchRunBudget` 提供可直接核对的分项统计。`optimizerCalls` 仅为初版兼容别名,新字段 `candidateRequestsAllStages` 明确包含搜索、缓存命中和复验请求;不带 `SlotsConsumed` 的 `backendSubmissions` 系列也仅是初版兼容别名,不能解释为后端已经接收。`timing` 使用全部已形成的试验记录计算墙钟、仿真耗时总计、最短、最长和平均值。
- `bestSearch` 只保存完整可行的最佳搜索点。没有可行点时它必须为 `null`,约束违反最小的完整不可行点只放入 `bestDiagnosticSearch`,报告必须明确称其为诊断点并展示归一化约束违反总量,不能称为方案或最佳可行点。
面向用户汇报时分别写“搜索停止”和“候选复验状态”,并同时报告搜索候选评估数、按阶段拆分的预算槽位占用与完成/失败记录、没有形成试验记录的槽位、缓存命中、未用搜索额度、完整代中有新仿真的代数与纯重复代数。不得把槽位占用称为后端已接收或已完成的仿真,也不得把 `solutionStatus: verified`、缓存命中次数、纯重复代、种群塌缩或理论预算容量解释为算法收敛证据。
本版没有持久缓存或 resume 命令。`checkpoint.json` 和 `optimization-events.jsonl` 仅用于审计已完成工作,不承诺中断后恢复同一种群。也没有自动重试:后端、网络或产物写入错误会按结构化错误中止,不悄悄再发起一次仿真。
只有 `status == completed`、`success == true`、结果变量键与单位仍匹配计划、所需序列存在、时间窗完整被覆盖且时间/数值结构正确时,才计算目标和约束。`failed`、`stalled`、`stopped`、取消或部分结果作为失败候选且不计分,`objectiveValue` 和 `objectiveLoss` 保持为空。若后端把缺失变量、错误单位或畸形序列标成成功完成,则视为结果合同错误并中止本次优化,避免继续解释不可靠数据。完整但不可行的点仍保留统计值,但不能优先于任何完整可行点。
## 最终复验、产物与措辞
没有完整可行候选时,`solutionStatus` 为 `noFeasibleCandidate`,`bestSearch` 为 `null`;可在 `bestDiagnosticSearch` 中报告归一化约束违反更小的完整不可行候选作诊断,但不生成 `best-*` 产物。存在最佳可行搜索候选时,脚本重新核对源 JSON SHA-256,从基准 XML 生成候选,使用新 simulation ID 并绕过缓存做一次完整新鲜仿真。
复验必须仍完整可行,复验结束时源 JSON SHA-256 仍与计划一致,且目标和每个约束原值均满足 `abs(search - verification) <= absoluteTolerance + relativeTolerance * max(abs(search), abs(verification))`。`validation` 只包含非负的 `relativeTolerance` 和 `absoluteTolerance`;复验次数固定为一,不是规格字段。通过时状态为 `verified`,否则为 `verificationFailed`,不用多次平均掩盖差异。`verified` 只表示最佳搜索候选通过了这次绕过缓存的新鲜复验;它不表示搜索收敛,不证明达到稳态,也不是全局最优证明。复验已经通过时,不再默认建议重跑同一项复验。
若目标统计量是 `final`,schema v2 的 `objectiveEndpointTrend` 会报告一次不影响复验状态的末段趋势诊断。只有新鲜复验本身完整成功,而且目标序列覆盖统计窗口终点或计划仿真终点时才分析;复验未完成、序列无效或未覆盖计划终点时标为 `unavailable` 并给出原因,不能把部分曲线末尾当成计划终点。它优先检查目标统计时段最后 `5%`,为取得至少 `6` 个样本可向前扩展,但最多使用最后 `20%`;仍不足时标为 `insufficientData`。相对量的尺度取“末段最大绝对值、完整时段最大绝对值的 `1e-6` 倍、最小正正规浮点数”三者的最大值。
末段净相对变化至少 `1%` 且非零相邻变化的方向一致率至少 `80%` 时记录 `directionalChange`;零增量不稀释方向一致率。末段相对峰峰范围至少 `2%` 时记录 `tailVariability`。任一条件成立就标为 `materialChangeDetected`。只有方向变化条件成立时才称为上升或下降;仅由范围条件触发时方向为 `fluctuating`,报告明显波动,并分别展示首尾净变化与峰峰范围,不能把振荡描述成单向趋势。结构化结果同时记录末段起止时刻、样本数、起止值、变化量、平均变化率、相对变化、相对范围、方向和检测原因。
这项检查只用于提醒“终点快照可能仍处于动态过程”。`steadyStateProven` 始终为 `false`;`noMaterialChangeDetected` 只能表述为“该启发式检查未发现明显末端变化”,不能写成“系统已达到稳态”。检查发现明显变化时,必须指出 `final` 结果只支持所选终点时刻的比较,不能外推成稳态性能更优。
优化输出包括 `optimization-plan.json`、`optimization-events.jsonl`、`simulation-progress/evaluation-NNNN.jsonl`、`checkpoint.json`、`evaluations.csv`、`optimization-result.json` 和 `report.md`。`evaluations.csv` 对每次形成试验记录的预算请求写一行;若本地准备或连接在形成试验记录前抛错,预算槽位占用可能比 CSV 行数多。`checkpoint.json` 是审计快照而不是 resume 状态。
只有 `solutionStatus == verified` 时才生成 `best-parameters.json`、`best-system.xml`、`best-project.json`、`result.json`、完整 `results.csv` 和目标/响应约束的独立 SVG 曲线。`best-project.json` 将被优化参数的原表达式替换为普通 SI 数值,源 JSON 不变。`optimize` 仅在状态为 `verified` 时返回退出码 `0`,其他结果返回 `4`。
只有 `solutionStatus == verified` 时,最终汇报才使用这一口径:
> 这是实际完成仿真的搜索点中表现最好的可行候选,并已通过一次独立复验;复验不证明搜索收敛、系统达到稳态或全局最优。
不使用“已找到全局最优”、“必然最优”或其他超出有限搜索证据的措辞。最佳候选位于用户确认的参数边界时,只能报告它是当前边界内实际搜索得到的边界点;在用户确认更宽范围符合物理、安全和组件合同前,不建议直接放宽边界或启动扩边界搜索,也不把有限采样点概括成整个连续区间上的严格单调规律。
## 内部命令与 OpenClaw 路径
以下命令供 Skill 实现和故障排查使用;正常交互不得要求用户手工运行命令、创建规格文件或复制确认参数。
从仓库根目录先预览计划:
```powershell
py -3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json `
--spec optimization-spec.json `
--output-dir OUTPUT_DIR `
--plan-file PLAN_FILE `
--present
```
普通 Skill 流程必须同时使用 `--plan-file` 和 `--present`:完整审计计划以 `0600` 权限独占写入 `PLAN_FILE`,stdout 只返回用户计划所需的白名单字段。`PLAN_FILE` 必须是位于 `OUTPUT_DIR` 外的新文件,不能覆盖既有文件,也不能与输出目录互为祖先或后代;默认用本次输出目录名加 UTC 时间戳或随机后缀生成同级文件,不复用固定的临时文件名。省略这两个选项的旧式完整 stdout 只用于兼容测试或显式审计排障,不用于普通对话。
向用户展示计划并获得明确确认后,在内部从 `PLAN_FILE` 读取并原样使用源 SHA、规格 SHA 和 `confirmationToken`,不要向用户展示:
```powershell
py -3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json `
--spec optimization-spec.json `
--output-dir OUTPUT_DIR `
--expected-source-sha256 SOURCE_SHA256 `
--expected-spec-sha256 SPEC_SHA256 `
--confirmation-token CONFIRMATION_TOKEN `
--confirmed
```
Linux 使用已确认的 Python 3.12 解释器和相同参数:
```bash
python3.12 skills/system-simulation/scripts/optimization_skill.py plan PROJECT.json \
--spec optimization-spec.json \
--output-dir OUTPUT_DIR \
--plan-file PLAN_FILE \
--present
python3.12 skills/system-simulation/scripts/optimization_skill.py optimize PROJECT.json \
--spec optimization-spec.json \
--output-dir OUTPUT_DIR \
--expected-source-sha256 SOURCE_SHA256 \
--expected-spec-sha256 SPEC_SHA256 \
--confirmation-token CONFIRMATION_TOKEN \
--confirmed
```
需要非默认后端或读取超时时,全局选项必须放在 `plan` / `optimize` 子命令之前,例如:
```bash
python3.12 skills/system-simulation/scripts/optimization_skill.py \
--base-url http://127.0.0.1:18082 --timeout 60 \
plan PROJECT.json --spec optimization-spec.json --output-dir OUTPUT_DIR \
--plan-file PLAN_FILE --present
```
OpenClaw 中不假设当前目录是仓库根目录,使用 Skill 根目录占位符:
```bash
python3.12 "{baseDir}/scripts/optimization_skill.py" plan PROJECT.json \
--spec optimization-spec.json \
--output-dir OUTPUT_DIR \
--plan-file PLAN_FILE \
--present
```
也可以先进入本 Skill 目录,再使用 `scripts/optimization_skill.py plan ...` 和 `scripts/optimization_skill.py optimize ...`。不根据用户主目录、OpenClaw 数据目录或仓库名称猜测脚本路径。持续消费 JSONL 进展,定期报告已占用/最大仿真预算槽位、已完成和失败记录、当前代数、最佳可行目标、缓存命中和内层仿真阶段;不把槽位占用说成后端已接收或已完成,也不因仿真时间短暂停滞而声称卡死。
通常省略 `--optimization-id` 让脚本生成唯一 ID。若显式指定,同一后端任务保留窗口内必须使用新的 ID;快速复用旧 ID 会被后端按冲突拒绝。
@@ -0,0 +1,48 @@
# 安全文本规范化策略
## 目的
`repair-format` 只解决可解析 JSON v1 或 XML v3 的文本层问题,使文件采用稳定的 UTF-8 和跨平台文本格式。它不是模型迁移器,也不是语义修复器。
## 允许的修改
仅允许脚本已经证明不会改变解析后数据合同的规范化,例如:
- 将可安全解码的输入统一写为 UTF-8;
- 统一 BOM 和换行表现;
- 规范化文件末尾换行;
- 对可解析内容采用脚本规定的稳定文本序列化形式。
以脚本返回的预览、变更摘要和哈希为准;不要在脚本外另写正则替换或自制格式化器。若文件连语法都无法可靠解析,停止并报告诊断,不能尝试猜测闭合括号、XML 标签或截断内容。
## 禁止的修改
本版不得自动执行下列动作:
- 新增、删除、更换或重命名组件和连接;
- 修改组件 ID、类型、端口、`modelVersion` 或 Schema 版本;
- 填猜缺失参数、改变数值、单位、离散选项或介质引用;
- 修改仿真起止时间、采样间隔、最大步长或求解算法;
- 把 XML v1/v2 或旧工程升级到当前版本;
- 根据报错放宽容差、删除失败组件或改变物理拓扑;
- 覆盖源文件,即使用户给出的输出路径通过大小写、相对路径或符号链接指向源文件也不行。
发现上述问题时,可以解释和给出人工处理建议,但不能借“修复格式”的名义实施。
## 强制确认流程
1. 对源文件运行 `inspect`,记录格式、诊断和 SHA-256。
2. 生成或读取 `repair-format` 的规范化预览,向用户说明只会改变哪些文本表现,并展示目标输出路径。
3. 等待用户针对该预览明确确认。笼统的“帮我看看”或先前对其他版本的确认不能复用。
4. 使用同一个源文件 SHA-256、预览返回的 `confirmationToken`、`--confirmed` 和预览中相同的 `--output` 路径执行写入。token 绑定源哈希、规范化输出哈希和目标绝对路径。
5. 如果哈希、规范化结果或目标路径已变化,停止并重新预览;不能绕过 `--expected-sha256` 或确认 token。
6. 对输出文件重新运行 `inspect`。只有重新校验通过且解析后的模型语义未改变时,才能报告完成。
示例命令形状见 [workflows.md](workflows.md)。
## 输出与交付
- 输出名称建议为原名加 `.normalized`,例如 `plant.normalized.json` 或 `plant.normalized.xml`。
- 保留源文件;清楚列出新文件、源 SHA-256、输出 SHA-256 和重新校验结果。
- 如果没有文本差异,说明文件无需规范化,不制造副本冒充修复结果。
- 如果写入失败或输出校验失败,不能把不完整文件当作成功结果交付。
@@ -0,0 +1,143 @@
# 命令与对话工作流
## CLI 合同
从仓库根目录调用:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py [--base-url URL] [--timeout SECONDS] COMMAND
```
若仓库已有 `.venv-win\Scripts\python.exe`,Windows 应优先用它替换 `py -3.12`;Linux 使用 `.venv/bin/python` 或 `python3.12`。不要使用本机可能指向旧版本的裸 `python`。默认服务地址为 `http://127.0.0.1:8000`,超时参数不得低于 10 秒,以保持在后端 5 秒心跳间隔之上。`inspect`、`repair-format`、`status` 和 `cancel` 在标准输出返回一个结构化 JSON;`simulate` 在标准输出给出节流后的 JSONL 进展和精简完成摘要。输出目录的 `progress.jsonl` 保留完整进展/错误事件,但只保存精简结果摘要;完整数值结果另存为 `result.json`,避免时间序列重复占用空间和智能体上下文。
退出码:
| 退出码 | 含义 |
| --- | --- |
| `0` | 命令按合同成功完成 |
| `2` | 输入、参数或安全前置条件错误 |
| `3` | HTTP、连接或后端结构化错误 |
| `4` | 仿真事件流报告失败 |
| `5` | 本地结果文件写入失败 |
不要只看退出码 `0` 就声称仿真数值成功;还要检查最终事件和 `result.json` 中的状态。不要通过匹配本地化消息文本判断状态。
## 检查与解释
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT --format auto
```
`--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。
对工程 JSON,`inspect` 的编译检查会安全计算受支持的连续参数表达式;原始组件数据仍显示用户输入的表达式。仿真时生成的临时 XML 只包含换算后的 SI 数值,不会回写 JSON。
默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT `
--component COMPONENT_ID `
--component-offset 0 `
--component-limit 50 `
--connection-offset 0 `
--connection-limit 25 `
--variable-query QUERY `
--variable-offset 0 `
--variable-limit 20
```
分别依据 `componentPage`、`connectionPage` 和 `resultVariablePage` 的 `hasMore`、`nextOffset` 继续分页,不要为寻找一个组件或变量请求全部详细合同。`componentTypes` 始终汇总完整模型,可先用它判断系统构成。`--component` 返回该 ID 的源文件数据和(若参与求解)编译合同,因此物性介质等配置节点也能查看参数。
## 文本规范化修复
先检查并取得源文件 SHA-256。第一次不带 `--confirmed` 调用只返回差异预览、不会写文件:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
--format auto `
--output OUTPUT `
--expected-sha256 SHA256
```
向用户展示预览中的目标路径、源/输出哈希和 `confirmationToken`,取得明确确认后,再用原样 token 执行写入:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py repair-format INPUT `
--format auto `
--output OUTPUT `
--expected-sha256 SHA256 `
--confirmation-token PREVIEW_TOKEN `
--confirmed
```
token 同时绑定源文件哈希、规范化输出哈希和目标绝对路径;任何一项变化都必须重新预览和确认。脚本拒绝覆盖源文件、哈希/token 不匹配和未确认写入。完整边界见 [repair-policy.md](repair-policy.md)。写入后再次运行 `inspect OUTPUT`。
## 仿真前对话
运行前必须完成以下判断:
1. `inspect` 通过,并取得可用结果变量清单。
2. 用户选择 `separate`、`overlay` 或 `stacked`。
3. 把自然语言对象解析为稳定结果 `key`;重名、缺单位或把输入参数误称为曲线时先澄清。
4. 向用户复述将运行的文件、仿真时段、算法、所选结果变量和曲线方式。
本版没有网页自动预装能力。用户要求“网页查看”时,说明当前只能直接交付 SVG 曲线与 CSV;不要启动浏览器、生成临时 URL,或声称现有页面会自动载入文件。
## 启动并监视仿真
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py simulate INPUT `
--format auto `
--output-dir OUTPUT_DIR `
--variables RESULT_KEY_1 RESULT_KEY_2 `
--chart-mode overlay `
--simulation-id SIMULATION_ID
```
`--variables` 接受结果变量稳定 `key`;不能传组件参数名。`--simulation-id` 可省略并由脚本生成,但应保存最终 ID,供恢复查询或取消使用。
监视规则:
- 消费 JSONL,记录最新 `progress`、`phase`、`simulatedTime/totalTime`、心跳和内部活动快照;
- 长任务期间定期向用户给出简短进展,避免逐条转发事件;
- 仅有仿真时间平台期不能证明卡死。活动序号、RHS、solver step、Jacobian 或闭合计数仍增长时,应报告“正在处理慢步”;
- 网络读取中断后,用已知 simulation ID 查询一次任务快照,再决定是否继续说明、恢复结果或报告连接问题;
- 不因运行缓慢自动取消。只有用户明确要求取消,或既有系统已经把任务判定为 stalled 时,才使用对应取消原因;
- `completed` 才表示完整完成;`stopped`、`stalled`、`failed` 都必须标明是非完整结果。
当前任务状态保存在后端进程内,终态记录只短期保留,服务重启后也不能恢复。本 Skill 不承诺跨进程或长期断线续传;需要查询时应及时保存 simulation ID、事件日志和已经写出的结果文件。
`status` 对已完成任务只输出结果摘要,不在终端重复打印整套时间序列;正常 `simulate` 流程会把完整数据保存为 `result.json` 和 `results.csv`。
恢复查询:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py status SIMULATION_ID
```
用户要求取消时:
```powershell
py -3.12 skills/system-simulation/scripts/simulation_skill.py cancel SIMULATION_ID --reason user
```
`--reason stalled` 只用于已有充分停滞证据的内部流程,不用来表达普通的用户取消。
## 结果文件与交付
正常运行目录应包含:
- `progress.jsonl`:原始进度、心跳和结束事件;
- `result.json`:最终结构化结果;
- `results.csv`:全部可用结果变量的 UTF-8 CSV;
- 根据 `separate`、`overlay` 或 `stacked` 生成的 SVG 曲线。
交付时:
1. 说明最终状态和实际计算到的仿真时间;
2. 返回用户选择的 SVG 曲线;
3. 无论用户只选了几条曲线,都同时返回完整 `results.csv`;
4. 若失败或取消但存在部分序列,明确标注曲线和 CSV 是部分结果;
5. 若没有产生可用时间序列,明确说明没有 CSV,不能创建空文件冒充结果;
6. 保留 `result.json` 和 `progress.jsonl` 作为诊断依据,但通常无需把完整事件日志逐行展示给用户。
本版不会根据结果自动改变模型并重试。诊断后若要改参数、拓扑或算法,先把建议交给用户,等待后续迭代能力或单独授权的人工修改流程。
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from xml.etree import ElementTree as ET
from app.main import (
ReactFlowNodePayload,
ReactFlowProjectPayload,
build_reactflow_system_xml,
reactflow_project_storage_data,
validate_reactflow_component_contract,
)
from app.parameter_expression import (
ParameterExpressionError,
evaluate_parameter_expression,
)
from app.simulation.registry import get_component_model_spec
from tests.test_amesim_pnvo001_signal_xml import amesim_pnvo001_signal_project
AREA_EXPRESSION = "3.14*10**2/4"
AREA_IN_SQUARE_METRES = 7.85e-5
def pnvo001_project_with_area(
area0: object,
*,
selected_unit: str = "mm2",
) -> ReactFlowProjectPayload:
project = amesim_pnvo001_signal_project().model_copy(deep=True)
valve = next(node for node in project.nodes if node.id == "valve_1")
valve.data.parameters["area0"] = area0
valve.data.parameterUnits["area0"] = selected_unit
return project
def pnvo001_node(project: ReactFlowProjectPayload) -> ReactFlowNodePayload:
return next(node for node in project.nodes if node.id == "valve_1")
class ParameterExpressionParserTests(unittest.TestCase):
def test_supported_arithmetic_constants_and_functions(self) -> None:
cases = {
"=3.14*10^2/4": 78.5,
"3.14*10**2/4": 78.5,
"(2 + 3) * 4": 20.0,
"2^3^2": 512.0,
"-2^2": -4.0,
"2.5E-3": 0.0025,
"sqrt(16) + abs(-2)": 6.0,
"sin(pi/2) + ln(e)": 2.0,
"max(1, 5, 3) + pow(2, 3)": 13.0,
}
for expression, expected in cases.items():
with self.subTest(expression=expression):
self.assertAlmostEqual(
evaluate_parameter_expression(expression),
expected,
places=12,
)
def test_invalid_or_unsafe_expressions_are_rejected(self) -> None:
cases = (
"",
"=",
"1 / 0",
"sqrt(-1)",
"pow(-1, 0.5)",
"unknown + 1",
"window.alert(1)",
"__import__('os')",
"1 + * 2",
"1e309",
"min()",
"max(" + ",".join("1" for _ in range(17)) + ")",
"(" * 34 + "1" + ")" * 34,
"1" * 513,
)
for expression in cases:
with self.subTest(expression=expression[:40]):
with self.assertRaises(ParameterExpressionError):
evaluate_parameter_expression(expression)
class ParameterExpressionExecutionTests(unittest.TestCase):
def test_pnvo001_area_expression_uses_selected_mm2_unit(self) -> None:
project = pnvo001_project_with_area(AREA_EXPRESSION)
valve = pnvo001_node(project)
spec = get_component_model_spec(valve.data.modelType)
parameters = validate_reactflow_component_contract(valve, spec)
self.assertAlmostEqual(
parameters["area0"],
AREA_IN_SQUARE_METRES,
places=15,
)
def test_plain_numeric_si_value_is_not_converted_again(self) -> None:
for stored_value in (AREA_IN_SQUARE_METRES, "7.85e-5"):
with self.subTest(stored_value=stored_value):
project = pnvo001_project_with_area(stored_value)
valve = pnvo001_node(project)
spec = get_component_model_spec(valve.data.modelType)
parameters = validate_reactflow_component_contract(valve, spec)
self.assertEqual(parameters["area0"], AREA_IN_SQUARE_METRES)
def test_storage_preserves_the_original_expression(self) -> None:
project = pnvo001_project_with_area(AREA_EXPRESSION)
stored = reactflow_project_storage_data(project)
stored_valve = next(
node for node in stored["nodes"] if node["id"] == "valve_1"
)
self.assertEqual(
stored_valve["data"]["parameters"]["area0"],
AREA_EXPRESSION,
)
self.assertEqual(
pnvo001_node(project).data.parameters["area0"],
AREA_EXPRESSION,
)
def test_xml_contains_resolved_si_value_without_mutating_project(self) -> None:
project = pnvo001_project_with_area(AREA_EXPRESSION)
xml_bytes = build_reactflow_system_xml(project)
root = ET.fromstring(xml_bytes)
area_parameter = root.find(
"./Components/Component[@id='valve_1']/Parameter[@name='area0']"
)
self.assertIsNotNone(area_parameter)
assert area_parameter is not None
self.assertAlmostEqual(
float(area_parameter.attrib["value"]),
AREA_IN_SQUARE_METRES,
places=15,
)
self.assertNotIn(AREA_EXPRESSION, xml_bytes.decode("utf-8"))
self.assertEqual(
pnvo001_node(project).data.parameters["area0"],
AREA_EXPRESSION,
)
def test_invalid_expression_has_stable_execution_error_code(self) -> None:
project = pnvo001_project_with_area("sqrt(-1)")
with self.assertRaisesRegex(
ValueError,
"PARAMETER_EXPRESSION_INVALID.*area0.*valve_1",
):
build_reactflow_system_xml(project)
def test_discrete_parameter_expression_is_rejected(self) -> None:
project = pnvo001_project_with_area(AREA_IN_SQUARE_METRES)
pnvo001_node(project).data.parameters["flowset"] = "1 + 0"
with self.assertRaisesRegex(
ValueError,
"PARAMETER_EXPRESSION_FORBIDDEN.*flowset.*valve_1",
):
build_reactflow_system_xml(project)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import unittest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
BASELINE_ROOT = REPOSITORY_ROOT / "tests" / "baselines" / "simulation"
GIT_ATTRIBUTES_PATH = REPOSITORY_ROOT / ".gitattributes"
EXPECTED_ATTRIBUTE_RULES = {
("tests/data/test-mql-8.xml", "text", "eol=lf"),
("tests/data/test-mql-8.json", "text", "eol=lf"),
("tests/data/test_mql-full-branches-01-04.xml", "text", "eol=lf"),
("tests/baselines/simulation/**/*.json", "text", "eol=lf"),
}
def _manifest_paths() -> tuple[Path, ...]:
return tuple(sorted(BASELINE_ROOT.glob("*/manifest.json")))
def _manifest_source_descriptors() -> tuple[tuple[Path, dict[str, object]], ...]:
descriptors: list[tuple[Path, dict[str, object]]] = []
for manifest_path in _manifest_paths():
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
source = manifest["source"]
descriptors.append((manifest_path, source))
companion = source.get("companionProject")
if companion is not None:
descriptors.append((manifest_path, companion))
return tuple(descriptors)
class RegressionFixtureLineEndingTests(unittest.TestCase):
def test_gitattributes_pin_byte_locked_artifacts_to_lf(self) -> None:
attribute_rules = {
tuple(line.split())
for raw_line in GIT_ATTRIBUTES_PATH.read_text(encoding="utf-8").splitlines()
if (line := raw_line.strip()) and not line.startswith("#")
}
self.assertTrue(
EXPECTED_ATTRIBUTE_RULES.issubset(attribute_rules),
"Raw-byte regression artifacts must be explicitly pinned to LF.",
)
def test_byte_locked_artifacts_contain_no_carriage_returns(self) -> None:
paths = {
path
for path in BASELINE_ROOT.rglob("*.json")
if path.is_file()
}
paths.update(
REPOSITORY_ROOT / str(descriptor["path"])
for _, descriptor in _manifest_source_descriptors()
)
self.assertTrue(paths, "No byte-locked regression artifacts were found.")
for path in sorted(paths):
with self.subTest(path=path.relative_to(REPOSITORY_ROOT).as_posix()):
self.assertNotIn(
b"\r",
path.read_bytes(),
"Byte-locked regression artifacts must use LF line endings.",
)
def test_manifest_source_identities_match_raw_worktree_bytes(self) -> None:
self.assertTrue(_manifest_paths(), "No regression manifests were found.")
for manifest_path, descriptor in _manifest_source_descriptors():
relative_path = str(descriptor["path"])
payload = (REPOSITORY_ROOT / relative_path).read_bytes()
label = f"{manifest_path.parent.name}:{relative_path}"
with self.subTest(source=label):
self.assertEqual(len(payload), descriptor["bytes"])
self.assertEqual(
hashlib.sha256(payload).hexdigest(),
descriptor["sha256"],
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff