Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c9f59d78 | ||
|
|
42ffdfff7d | ||
|
|
a87d462e94 |
No files matched your search
+46
-3
@@ -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):
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,40 +1,55 @@
|
||||
---
|
||||
name: system-simulation
|
||||
description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,并在用户选定结果曲线后启动、监视或取消仿真及导出 CSV。适用于检查模型文件、修复编码或换行、运行仿真和获取结果;不用于旧格式迁移、任意语义修复、自动迭代或网页自动预装。
|
||||
description: 读取、校验并简要解释 SystemSimulationApp 工程 JSON v1 或 System XML v3,安全规范化文件文本,运行并监视仿真、导出结果,以及在用户确认计划后对 JSON v1 执行单目标、有界连续 SI 参数优化。适用于检查模型、修复编码或换行、运行仿真、获取结果和优化结果统计量;不用于旧格式迁移、任意语义修复、离散或拓扑优化、多目标优化或网页自动预装。
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
bins: [python3.12]
|
||||
---
|
||||
|
||||
# 系统仿真
|
||||
|
||||
使用本 Skill 随附的确定性脚本检查模型、调用现有后端并保存结果;不要让语言模型自行重写模型或猜测求解数据。
|
||||
使用本 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/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. 仿真前必须让用户选择直接曲线查看方式,并解析具体结果变量:
|
||||
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、稳定错误码和退出码做判断,不解析中文提示文本来驱动下一步。
|
||||
@@ -1,7 +1,7 @@
|
||||
interface:
|
||||
display_name: "系统仿真文件助手"
|
||||
short_description: "读取与校验模型文件,监视仿真并导出结果曲线和 CSV 文件"
|
||||
default_prompt: "使用 $system-simulation 检查我的模型文件,并在我选定结果曲线后运行和监视仿真。"
|
||||
display_name: "系统仿真与参数优化助手"
|
||||
short_description: "用自然语言检查、仿真模型,并按安全默认值规划连续参数优化"
|
||||
default_prompt: "使用 $system-simulation 按我描述的目标优化模型参数;请自动补齐安全默认值,先给出易读计划,等我一次确认后执行。"
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -30,10 +30,14 @@ 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
|
||||
|
||||
|
||||
@@ -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 会被后端按冲突拒绝。
|
||||
@@ -29,6 +29,7 @@ py -3.12 skills/system-simulation/scripts/simulation_skill.py inspect INPUT --fo
|
||||
```
|
||||
|
||||
`--format` 可为 `auto`、`json` 或 `xml`。完成后按 [file-contracts.md](file-contracts.md) 解释模型。错误和警告应保留层级、稳定错误码、路径或行号;不要只复述最后一句消息。
|
||||
对工程 JSON,`inspect` 的编译检查会安全计算受支持的连续参数表达式;原始组件数据仍显示用户输入的表达式。仿真时生成的临时 XML 只包含换算后的 SI 数值,不会回写 JSON。
|
||||
|
||||
默认只返回首批 50 个紧凑组件、25 条连接和 20 个结果变量,避免大型工程输出撑满上下文。翻阅模型摘要、按组件查看完整合同或搜索结果变量时使用:
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,7 +27,7 @@ import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
MAX_INPUT_BYTES = 5 * 1024 * 1024
|
||||
@@ -1294,12 +1294,16 @@ def _read_simulation_stream(
|
||||
simulation_id: str,
|
||||
timeout: float,
|
||||
progress_path: Path,
|
||||
*,
|
||||
event_sink: Callable[[object], None] | None = None,
|
||||
full_result: str | None = "result.json",
|
||||
) -> 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
|
||||
sink = event_sink or emit_json
|
||||
try:
|
||||
progress = progress_path.open("x", encoding="utf-8", newline="\n")
|
||||
except OSError as exc:
|
||||
@@ -1333,7 +1337,11 @@ def _read_simulation_stream(
|
||||
)
|
||||
event_kind = event.get("event")
|
||||
logged_event = (
|
||||
_public_result_event(event, simulation_id)
|
||||
_public_result_event(
|
||||
event,
|
||||
simulation_id,
|
||||
full_result=full_result,
|
||||
)
|
||||
if event_kind == "result"
|
||||
else event
|
||||
)
|
||||
@@ -1348,16 +1356,16 @@ def _read_simulation_stream(
|
||||
previous_progress=public_progress,
|
||||
seconds_since_emit=now - public_emit_time,
|
||||
):
|
||||
emit_json(event)
|
||||
sink(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)
|
||||
sink(logged_event)
|
||||
else:
|
||||
emit_json(event)
|
||||
sink(event)
|
||||
if event_kind == "result":
|
||||
result = event.get("result")
|
||||
if isinstance(result, dict):
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user