完成求解器雅可比矩阵首轮优化,增加更新目录,整理了文档文件夹,增加了服务启动脚本

This commit is contained in:
lujingze committed 2026-08-17 07:33:31 +00:00
1 parent 6bb0591d32
commit 16a7eb2d6c
48 files changed
+8172 -217

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
*.bat text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
+4
View File
@@ -12,6 +12,10 @@ htmlcov/
# Local virtual environments # Local virtual environments
.venv/ .venv/
.venv-win/ .venv-win/
# Local Linux toolchain (downloaded for the startup scripts)
.tools/node-*-linux-x64/
app/data/ app/data/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
+66 -10
View File
@@ -2,6 +2,67 @@
ReactFlow 系统建模与 `app.simulation` 仿真后端。 ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备
后端依赖分别安装在平台对应的虚拟环境中。
Windows:
```powershell
py -3 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
```
Linux:
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
```
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
Windows(PowerShell,使用仓库内的便携 Node.js):
```powershell
$nodeDir = Get-ChildItem .tools -Directory -Filter "node-*-win-x64" |
Where-Object { (Test-Path "$($_.FullName)\node.exe") -and (Test-Path "$($_.FullName)\npm.cmd") } |
Select-Object -First 1
& "$($nodeDir.FullName)\npm.cmd" --prefix frontend ci
```
Linux:
```bash
cd frontend
npm ci
cd ..
```
Windows 启动脚本会自动使用 `.tools/node-*-win-x64` 下兼容的便携 Node.js;Linux 启动脚本优先使用 `.tools/node-*-linux-x64` 下兼容的运行时(如果存在),否则使用 `PATH` 中的 `node` 和 `npm`。`start-all.sh` 需要 Bash 4.3 或更高版本。
## 启动项目
脚本统一存放在 `bat/` 目录。三个入口分别用于同时启动、只启动后端、只启动前端。
Windows:
```bat
bat\start-all.bat
bat\start-backend.bat
bat\start-reactflow.bat
```
Linux:
```bash
./bat/start-all.sh
./bat/start-backend.sh
./bat/start-reactflow.sh
```
后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。
## 后端接口 ## 后端接口
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。 - `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
@@ -19,19 +80,14 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。 当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用: XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。
```powershell
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
```
## 文档 ## 文档
- [开发文档索引](docs/README.md) - [开发文档索引](docs/README.md)
- [后端接口版本与定义规范 v1](docs/backend-interface-version-spec-v1.md) - [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md)
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md) - [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md)
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md) - [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md)
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json) - [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
- [System XML v3 协议(当前规范)](docs/system-xml-v3.md) - [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md)
- [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd) - [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd)
+4 -4
View File
@@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件
公开临时库入口是 `components/amesim/library.py`。公开模型必须在 公开临时库入口是 `components/amesim/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS / 模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见 RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和 [`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。 [`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。
当前关键文件: 当前关键文件:
@@ -88,7 +88,7 @@ Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行
`diagnostics.performance.propertyCache` 中返回。 `diagnostics.performance.propertyCache` 中返回。
基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见 基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见
[`仿真性能评估 2026-08-15`](../../docs/仿真性能评估-2026-08-15.md)。 [`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。
## 当前阶段进度 ## 当前阶段进度
@@ -311,7 +311,7 @@ print(result.used_modelica_reference)
## 基线结果 ## 基线结果
当前基线对比摘要来自: 当前基线对比摘要来自:
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt) [`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt)
当前四个主变量的最大误差为: 当前四个主变量的最大误差为:
+219 -3
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from math import isclose, log, log10, pi, sqrt, tanh from math import isclose, isfinite, log, log10, pi, sqrt, tanh
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER, AMESIM_GAS_INDEX_PARAMETER,
@@ -22,11 +23,35 @@ from app.simulation.core.metadata import (
ResultVariableDefinition, ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from app.simulation.core.medium import GasMedium, ThermodynamicProperties from app.simulation.core.medium import (
GasMedium,
ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
)
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
class Pnl0001MassFlowLinearization:
value: float
partial_p_1: float
partial_p_2: float
partial_temperature: float
valid: bool = True
reason: str | None = None
direction: str = "forward"
@dataclass(frozen=True)
class Pnl0001DerivativeLinearization:
derivative: tuple[float, float]
tangents: tuple[tuple[float, ...], tuple[float, ...]]
properties: ThermodynamicPropertiesLinearization
valid: bool = True
reason: str | None = None
_MAX_REPORTED_FRICTION_FACTOR = 64_000_000.0 _MAX_REPORTED_FRICTION_FACTOR = 64_000_000.0
@@ -829,6 +854,105 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
) )
return magnitude if pressure_difference > 0.0 else -magnitude return magnitude if pressure_difference > 0.0 else -magnitude
def linearize_mass_flow(
self,
p_1: float,
p_2: float,
temperature: float,
*,
relative_step: float = 2.0 ** -26,
slope_relative_tolerance: float = 5.0e-3,
) -> Pnl0001MassFlowLinearization:
"""Audit local flow-law slopes without perturbing the full system RHS."""
p_1 = float(p_1)
p_2 = float(p_2)
temperature = float(temperature)
direction = "forward" if p_1 > p_2 else "reverse"
value = self.mass_flow(p_1, p_2, temperature)
def invalid(reason: str) -> Pnl0001MassFlowLinearization:
return Pnl0001MassFlowLinearization(
value=value,
partial_p_1=0.0,
partial_p_2=0.0,
partial_temperature=0.0,
valid=False,
reason=reason,
direction=direction,
)
if not all(isfinite(item) for item in (p_1, p_2, temperature, value)):
return invalid("non_finite_primal")
pressure_gap = abs(p_1 - p_2)
if pressure_gap <= 1.0e-8:
return invalid("flow_direction_boundary")
if temperature <= 1.0 * (1.0 + 1.0e-10):
return invalid("temperature_floor_boundary")
if relative_step <= 0.0 or slope_relative_tolerance <= 0.0:
raise ValueError("PNL0001 slope audit tolerances must be positive.")
pressure_step = min(
relative_step * max(abs(p_1), abs(p_2), 1.0),
0.25 * pressure_gap,
)
temperature_step = min(
relative_step * max(abs(temperature), 1.0),
0.25 * (temperature - 1.0),
)
if pressure_step <= 0.0 or temperature_step <= 0.0:
return invalid("unresolved_local_step")
arguments = (p_1, p_2, temperature)
argument_names = ("p_1", "p_2", "temperature")
steps = (pressure_step, pressure_step, temperature_step)
partials: list[float] = []
for argument_index, (argument, step) in enumerate(
zip(arguments, steps, strict=True)
):
lower = list(arguments)
upper = list(arguments)
lower[argument_index] = argument - step
upper[argument_index] = argument + step
lower_value = self.mass_flow(*lower)
upper_value = self.mass_flow(*upper)
left_slope = (value - lower_value) / step
right_slope = (upper_value - value) / step
slope_scale = max(
abs(left_slope),
abs(right_slope),
abs(value) / max(abs(argument), 1.0),
1.0e-12,
)
if not all(
isfinite(item)
for item in (
lower_value,
upper_value,
left_slope,
right_slope,
)
):
return invalid(
f"non_finite_local_slope:{argument_names[argument_index]}"
)
if (
abs(left_slope - right_slope)
> slope_relative_tolerance * slope_scale
):
return invalid(
f"local_slope_disagreement:{argument_names[argument_index]}"
)
partials.append(0.5 * (left_slope + right_slope))
return Pnl0001MassFlowLinearization(
value=value,
partial_p_1=partials[0],
partial_p_2=partials[1],
partial_temperature=partials[2],
direction=direction,
)
def component_result_values(self) -> Mapping[str, float]: def component_result_values(self) -> Mapping[str, float]:
props = self.properties() props = self.properties()
flow = self.mass_flow(self.port_1.p, props.p, props.T) flow = self.mass_flow(self.port_1.p, props.p, props.T)
@@ -913,6 +1037,98 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
) )
return derivative.as_vector() return derivative.as_vector()
def linearize_state_derivative(
self,
connected_h: Mapping[str, float],
*,
state_mass_tangent: Sequence[float],
state_energy_tangent: Sequence[float],
port_mass_flow_tangents: Mapping[str, Sequence[float]],
connected_h_tangents: Mapping[str, Sequence[float]],
property_linearization: ThermodynamicPropertiesLinearization | None = None,
flow_boundary_tolerance: float = 1.0e-12,
) -> Pnl0001DerivativeLinearization:
"""Linearize the pipe storage balance in a fixed stream mode."""
port_names = ("port_1", "port_2")
vectors = {
"state_mass": tuple(float(value) for value in state_mass_tangent),
"state_energy": tuple(float(value) for value in state_energy_tangent),
}
for port_name in port_names:
vectors[f"flow:{port_name}"] = tuple(
float(value) for value in port_mass_flow_tangents[port_name]
)
vectors[f"enthalpy:{port_name}"] = tuple(
float(value) for value in connected_h_tangents[port_name]
)
widths = {len(values) for values in vectors.values()}
if len(widths) != 1:
raise ValueError("PNL0001 tangent vectors must have equal lengths.")
width = len(vectors["state_mass"])
invalid_reason: str | None = None
if not all(isfinite(value) for values in vectors.values() for value in values):
invalid_reason = "non_finite_tangent_input"
properties = property_linearization or self.medium.linearize_properties_from_mU(
self.state.m,
self.state.U,
self.volume,
vectors["state_mass"],
vectors["state_energy"],
(0.0,) * width,
)
if properties.tangents.width != width:
raise ValueError(
"PNL0001 property tangent width must match balance tangents."
)
props = properties.properties
if not properties.valid:
invalid_reason = invalid_reason or properties.reason
mass_derivative = self.port_1.m_flow + self.port_2.m_flow
energy_derivative = self.thermal_energy_flow_w(props.T)
mass_tangent = [0.0] * width
thermal_coefficient = (
0.0 if self.mode == 1 else self.kth * self.exchange_area
)
energy_tangent = [
-thermal_coefficient * properties.tangents.T[index]
for index in range(width)
]
for port_name in port_names:
port = self.get_port(port_name)
flow_tangent = vectors[f"flow:{port_name}"]
if (
abs(port.m_flow) <= flow_boundary_tolerance
and any(value != 0.0 for value in flow_tangent)
):
invalid_reason = invalid_reason or (
f"flow_direction_boundary:{port_name}"
)
if port.m_flow > 0.0:
inlet_h = connected_h[port_name]
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
else:
inlet_h = props.h
inlet_h_tangent = properties.tangents.h
energy_derivative += port.m_flow * inlet_h
for index in range(width):
mass_tangent[index] += flow_tangent[index]
energy_tangent[index] += (
inlet_h * flow_tangent[index]
+ port.m_flow * inlet_h_tangent[index]
)
return Pnl0001DerivativeLinearization(
derivative=(mass_derivative, energy_derivative),
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
properties=properties,
valid=invalid_reason is None,
reason=invalid_reason,
)
class AmesimPnl0002(AmesimPnl0001): class AmesimPnl0002(AmesimPnl0001):
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from math import pi from dataclasses import dataclass
from math import isfinite, pi
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER, AMESIM_GAS_INDEX_PARAMETER,
@@ -18,6 +19,18 @@ from app.simulation.core.ports import PortDefinition
AMESIM_REFERENCE_PRESSURE_PA = 101300.0 AMESIM_REFERENCE_PRESSURE_PA = 101300.0
@dataclass(frozen=True)
class Pnrp17Linearization:
volume: float
volume_flow: float
pressure_force: float
volume_tangent: tuple[float, ...]
volume_flow_tangent: tuple[float, ...]
pressure_force_tangent: tuple[float, ...]
valid: bool = True
reason: str | None = None
class AmesimPnrp17(AlgebraicComponent): class AmesimPnrp17(AlgebraicComponent):
"""AMESim PNRP17 pneumatic piston with two mechanical faces. """AMESim PNRP17 pneumatic piston with two mechanical faces.
@@ -230,6 +243,53 @@ class AmesimPnrp17(AlgebraicComponent):
def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]:
return {"port_1": (self.chamber_volume, self.chamber_volume_flow)} return {"port_1": (self.chamber_volume, self.chamber_volume_flow)}
def linearize_geometry_and_force(
self,
port_4_x_tangent: Sequence[float],
port_5_x_tangent: Sequence[float],
port_4_v_tangent: Sequence[float],
port_5_v_tangent: Sequence[float],
port_1_pressure_tangent: Sequence[float],
) -> Pnrp17Linearization:
"""Return exact piston geometry and pressure-force tangents."""
vectors = tuple(
tuple(float(value) for value in values)
for values in (
port_4_x_tangent,
port_5_x_tangent,
port_4_v_tangent,
port_5_v_tangent,
port_1_pressure_tangent,
)
)
widths = {len(values) for values in vectors}
if len(widths) != 1:
raise ValueError("PNRP17 tangent vectors must have equal lengths.")
valid = all(isfinite(value) for values in vectors for value in values)
area = self.effective_area
volume_tangent = tuple(
area * (right - left)
for left, right in zip(vectors[0], vectors[1], strict=True)
)
volume_flow_tangent = tuple(
area * (right - left)
for left, right in zip(vectors[2], vectors[3], strict=True)
)
pressure_force_tangent = tuple(
area * value for value in vectors[4]
)
return Pnrp17Linearization(
volume=self.chamber_volume,
volume_flow=self.chamber_volume_flow,
pressure_force=self.pressure_force,
volume_tangent=volume_tangent,
volume_flow_tangent=volume_flow_tangent,
pressure_force_tangent=pressure_force_tangent,
valid=valid,
reason=None if valid else "non_finite_tangent_input",
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h.get( self.port_1.h_outflow = connected_h.get(
"port_1", "port_1",
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from math import expm1 from dataclasses import dataclass
from math import expm1, isfinite
from app.simulation.core.base import AlgebraicComponent, DynamicComponent from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.catalog import ( from app.simulation.core.catalog import (
@@ -20,6 +21,24 @@ from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
@dataclass(frozen=True)
class Mecmas21DerivativeLinearization:
derivative: tuple[float, float]
tangents: tuple[tuple[float, ...], tuple[float, ...]]
mode: str
valid: bool = True
reason: str | None = None
@dataclass(frozen=True)
class LstpContactForceLinearization:
force: float
force_tangent: tuple[float, ...]
mode: str
valid: bool = True
reason: str | None = None
_MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,)) _MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,))
_MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0)) _MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0))
_MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0)) _MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0))
@@ -723,6 +742,204 @@ class AmesimMecmas21(DynamicComponent):
) )
return [self.acceleration(), velocity] return [self.acceleration(), velocity]
def linearize_state_derivative(
self,
port_1_force_tangent: Sequence[float],
port_2_force_tangent: Sequence[float],
velocity_tangent: Sequence[float],
position_tangent: Sequence[float],
*,
constraint_mode: str = "current",
boundary_tolerance: float = 1.0e-12,
) -> Mecmas21DerivativeLinearization:
"""Linearize one inertia in a declared fixed mechanical mode."""
vectors = tuple(
tuple(float(value) for value in values)
for values in (
port_1_force_tangent,
port_2_force_tangent,
velocity_tangent,
position_tangent,
)
)
widths = {len(values) for values in vectors}
if len(widths) != 1:
raise ValueError("MECMAS21 tangent vectors must have equal lengths.")
width = len(vectors[0])
invalid_reason: str | None = None
if not all(isfinite(value) for values in vectors for value in values):
invalid_reason = "non_finite_tangent_input"
requested_mode = constraint_mode
if requested_mode == "current":
fixed = (
self._constraint_acceleration == 0.0
and self._constraint_velocity == 0.0
)
mode = "fixed" if fixed else "free"
if self._constraint_acceleration is not None and not fixed:
invalid_reason = invalid_reason or (
"group_acceleration_requires_aggregate"
)
elif requested_mode == "free":
mode = "free"
elif requested_mode in {"lower", "upper"}:
mode = requested_mode
fixed = (
self._constraint_acceleration == 0.0
and self._constraint_velocity == 0.0
)
if not fixed:
invalid_reason = invalid_reason or (
"constraint_mode_not_statically_fixed"
)
elif requested_mode == "uninitialized":
mode = requested_mode
invalid_reason = invalid_reason or "constraint_mode_uninitialized"
else:
raise ValueError(
"MECMAS21 constraint_mode must be current, free, lower, upper, "
"or uninitialized."
)
if mode in {"fixed", "lower", "upper"}:
return Mecmas21DerivativeLinearization(
derivative=(self.acceleration(), 0.0),
tangents=((0.0,) * width, (0.0,) * width),
mode=mode,
valid=invalid_reason is None,
reason=invalid_reason,
)
force_1_tangent, force_2_tangent, dv, dx = vectors
acceleration_tangent = [
force_1_tangent[index] + force_2_tangent[index]
for index in range(width)
]
if self.use_friction:
for index in range(width):
acceleration_tangent[index] += (
-self.rvisc * dv[index]
- 2.0 * self.wind * abs(self.v) * dv[index]
)
if (
self.fcoul != 0.0
and abs(self.v) <= boundary_tolerance
and any(value != 0.0 for value in dv)
):
invalid_reason = invalid_reason or "dry_friction_direction_boundary"
def add_limit_tangent(
*,
side: str,
stiffness: float,
damping: float,
damping_penetration: float,
bound: float,
damping_sign: float,
force_sign: float,
) -> None:
nonlocal invalid_reason
if int(self.stoptype) != 2:
return
penetration = (
bound - self.x if side == "lower" else self.x - bound
)
penetration_tangent = tuple(
(-value if side == "lower" else value) for value in dx
)
scale = max(abs(bound), abs(self.x), 1.0)
if penetration <= 0.0:
if (
abs(penetration) <= boundary_tolerance * scale
and any(value != 0.0 for value in penetration_tangent)
):
invalid_reason = invalid_reason or (
f"soft_endstop_mode_boundary:{side}"
)
return
if damping_penetration > 0.0:
fraction = min(penetration / damping_penetration, 1.0)
if penetration < damping_penetration:
fraction_tangent = tuple(
value / damping_penetration
for value in penetration_tangent
)
else:
fraction_tangent = (0.0,) * width
if (
abs(penetration - damping_penetration)
<= boundary_tolerance
* max(abs(damping_penetration), 1.0)
and any(value != 0.0 for value in penetration_tangent)
):
invalid_reason = invalid_reason or (
f"soft_endstop_damping_boundary:{side}"
)
else:
fraction = 1.0
fraction_tangent = (0.0,) * width
raw_force = (
stiffness * penetration
+ damping_sign * fraction * damping * self.v
)
raw_tangent = tuple(
stiffness * penetration_tangent[index]
+ damping_sign
* damping
* (
fraction * dv[index]
+ self.v * fraction_tangent[index]
)
for index in range(width)
)
if int(self.discContactOption) != 1 and raw_force <= 0.0:
if (
abs(raw_force)
<= boundary_tolerance
* max(abs(stiffness * penetration), 1.0)
and any(value != 0.0 for value in raw_tangent)
):
invalid_reason = invalid_reason or (
f"soft_endstop_force_boundary:{side}"
)
return
for index in range(width):
acceleration_tangent[index] += (
force_sign * raw_tangent[index]
)
add_limit_tangent(
side="lower",
stiffness=self.Kbmin,
damping=self.Dbmin,
damping_penetration=self.Pdmin,
bound=self.xmin,
damping_sign=-1.0,
force_sign=1.0,
)
add_limit_tangent(
side="upper",
stiffness=self.Kbmax,
damping=self.Dbmax,
damping_penetration=self.Pdmax,
bound=self.xmax,
damping_sign=1.0,
force_sign=-1.0,
)
acceleration_tangent = tuple(
value / self.mass for value in acceleration_tangent
)
return Mecmas21DerivativeLinearization(
derivative=(self.unconstrained_acceleration(), self.v),
tangents=(acceleration_tangent, tuple(dv)),
mode=mode,
valid=invalid_reason is None,
reason=invalid_reason,
)
def component_result_values(self) -> Mapping[str, float]: def component_result_values(self) -> Mapping[str, float]:
return { return {
"a": self.acceleration(), "a": self.acceleration(),
@@ -979,6 +1196,112 @@ class AmesimLstp00a(AlgebraicComponent):
) )
return force if int(self.discContactOption) == 1 else max(force, 0.0) return force if int(self.discContactOption) == 1 else max(force, 0.0)
def linearize_contact_force(
self,
port_1_x_tangent: Sequence[float],
port_2_x_tangent: Sequence[float],
port_1_velocity_tangent: Sequence[float],
port_2_velocity_tangent: Sequence[float],
*,
boundary_tolerance: float = 1.0e-12,
) -> LstpContactForceLinearization:
"""Linearize the elastic contact in its current unilateral mode."""
vectors = tuple(
tuple(float(value) for value in values)
for values in (
port_1_x_tangent,
port_2_x_tangent,
port_1_velocity_tangent,
port_2_velocity_tangent,
)
)
widths = {len(values) for values in vectors}
if len(widths) != 1:
raise ValueError("LSTP00A tangent vectors must have equal lengths.")
width = len(vectors[0])
if not all(isfinite(value) for values in vectors for value in values):
return LstpContactForceLinearization(
force=self.contact_force,
force_tangent=(0.0,) * width,
mode="invalid",
valid=False,
reason="non_finite_tangent_input",
)
dx_1, dx_2, dv_1, dv_2 = vectors
penetration_tangent = tuple(
left - right for left, right in zip(dx_1, dx_2, strict=True)
)
velocity_tangent = tuple(
left - right for left, right in zip(dv_1, dv_2, strict=True)
)
overlap = -self.gap
force = self.contact_force
scale = max(abs(self.gap0), abs(self.port_1.x), abs(self.port_2.x), 1.0)
if overlap <= 0.0:
on_boundary = abs(overlap) <= boundary_tolerance * scale
crossing = any(value != 0.0 for value in penetration_tangent)
return LstpContactForceLinearization(
force=force,
force_tangent=(0.0,) * width,
mode="boundary" if on_boundary else "inactive",
valid=not (on_boundary and crossing),
reason=(
"contact_mode_boundary"
if on_boundary and crossing
else None
),
)
penetration = overlap
if self.Pdis > 0.0:
damping_fraction = -expm1(-penetration / self.Pdis)
damping_fraction_tangent = tuple(
(1.0 - damping_fraction) * value / self.Pdis
for value in penetration_tangent
)
else:
damping_fraction = 1.0
damping_fraction_tangent = (0.0,) * width
relative_velocity = self.penetration_velocity
raw_force = (
self.kcont * penetration
+ damping_fraction * self.rcont * relative_velocity
)
raw_tangent = tuple(
self.kcont * penetration_tangent[index]
+ self.rcont
* (
damping_fraction * velocity_tangent[index]
+ relative_velocity * damping_fraction_tangent[index]
)
for index in range(width)
)
if int(self.discContactOption) != 1 and raw_force <= 0.0:
on_boundary = (
abs(raw_force)
<= boundary_tolerance
* max(abs(self.kcont * penetration), 1.0)
)
crossing = any(value != 0.0 for value in raw_tangent)
return LstpContactForceLinearization(
force=force,
force_tangent=(0.0,) * width,
mode="force_boundary" if on_boundary else "clamped",
valid=not (on_boundary and crossing),
reason=(
"contact_force_boundary"
if on_boundary and crossing
else None
),
)
return LstpContactForceLinearization(
force=force,
force_tangent=raw_tangent,
mode="active",
)
def clear_causal_contact(self) -> None: def clear_causal_contact(self) -> None:
self._causal_penetration = None self._causal_penetration = None
self._causal_contact_force = None self._causal_contact_force = None
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from math import isfinite
from typing import ClassVar from typing import ClassVar
from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.core.errors import RecoverableTrialStateError
@@ -9,6 +10,8 @@ from app.simulation.core.medium import (
GasMedium, GasMedium,
IdealGasMedium, IdealGasMedium,
ThermodynamicProperties, ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
ThermodynamicPropertyTangents,
) )
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.performance import profile_property, record_property_iterations from app.simulation.performance import profile_property, record_property_iterations
@@ -309,6 +312,153 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
), ),
) )
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Implicitly differentiate the Peng-Robinson m/U/V recovery."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
def invalid(reason: str) -> ThermodynamicPropertiesLinearization:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason=reason,
)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return invalid("properties_primal_mismatch")
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return invalid("non_finite_tangent_input")
if props.T <= 2.2 * (1.0 + 1.0e-10):
return invalid("temperature_floor_boundary")
pressure_temperature_derivative = (
self.fluid.pressure_temperature_derivative_at_density(
props.T,
props.rho,
)
)
pressure_density_derivative = (
self.fluid.pressure_density_derivative_at_temperature(
props.T,
props.rho,
)
)
cv = (
self.cv_at_temperature(props.T)
+ self.fluid.residual_isochoric_heat_capacity_at_density(
props.T,
props.rho,
)
)
recovered_internal_energy = (
self.specific_internal_energy(props.T)
+ self.fluid.residual_specific_internal_energy_at_density(
props.T,
props.rho,
)
)
recovery_scale = max(
abs(props.u),
abs(cv * props.T) if isfinite(cv) else 0.0,
1.0,
)
if (
not all(
isfinite(value)
for value in (
pressure_temperature_derivative,
pressure_density_derivative,
cv,
recovered_internal_energy,
)
)
or cv <= 0.0
):
return invalid("invalid_peng_robinson_derivative")
if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale:
return invalid("properties_recovery_not_converged")
internal_energy_density_derivative = (
props.p - props.T * pressure_temperature_derivative
) / (props.rho * props.rho)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = (
mass_tangent / V - m * volume_tangent / (V * V)
)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = (
internal_energy_tangent
- internal_energy_density_derivative * density_tangent
) / cv
pressure_tangent = (
pressure_temperature_derivative * temperature_tangent
+ pressure_density_derivative * density_tangent
)
enthalpy_tangent = (
internal_energy_tangent
+ pressure_tangent / props.rho
- props.p * density_tangent / (props.rho * props.rho)
)
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
if not all(isfinite(value) for value in tangent_values):
return invalid("non_finite_property_tangent")
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class AmesimGasPropertyModelSpec: class AmesimGasPropertyModelSpec:
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
from app.simulation.components.amesim.gases import ( from app.simulation.components.amesim.gases import (
AMESIM_GAS_INDEX_PARAMETER, AMESIM_GAS_INDEX_PARAMETER,
@@ -14,11 +16,24 @@ from app.simulation.core.metadata import (
ResultVariableDefinition, ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES, THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
) )
from app.simulation.core.medium import GasMedium, ThermodynamicProperties from app.simulation.core.medium import (
GasMedium,
ThermodynamicProperties,
ThermodynamicPropertiesLinearization,
)
from app.simulation.core.ports import PortDefinition from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState from app.simulation.core.state import VolumeState
@dataclass(frozen=True)
class Pnch012DerivativeLinearization:
derivative: tuple[float, float]
tangents: tuple[tuple[float, ...], tuple[float, ...]]
properties: ThermodynamicPropertiesLinearization
valid: bool = True
reason: str | None = None
class AmesimPnch023(ThermodynamicVolumeComponent): class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange. """AMESim PNCH023 simple pneumatic chamber with heat exchange.
@@ -518,6 +533,132 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
energy_derivative -= props.p * self.total_volume_rate() energy_derivative -= props.p * self.total_volume_rate()
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector() return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
def linearize_state_derivative(
self,
connected_h: Mapping[str, float],
*,
state_mass_tangent: Sequence[float],
state_energy_tangent: Sequence[float],
external_volume_tangent: Sequence[float],
external_volume_rate_tangent: Sequence[float],
port_mass_flow_tangents: Mapping[str, Sequence[float]],
connected_h_tangents: Mapping[str, Sequence[float]],
property_linearization: ThermodynamicPropertiesLinearization | None = None,
flow_boundary_tolerance: float = 1.0e-12,
) -> Pnch012DerivativeLinearization:
"""Linearize the chamber balance while keeping stream modes fixed."""
port_names = ("port_1", "port_2", "port_3", "port_4")
vectors = {
"state_mass": tuple(float(value) for value in state_mass_tangent),
"state_energy": tuple(float(value) for value in state_energy_tangent),
"volume": tuple(float(value) for value in external_volume_tangent),
"volume_rate": tuple(
float(value) for value in external_volume_rate_tangent
),
}
for port_name in port_names:
vectors[f"flow:{port_name}"] = tuple(
float(value) for value in port_mass_flow_tangents[port_name]
)
vectors[f"enthalpy:{port_name}"] = tuple(
float(value) for value in connected_h_tangents[port_name]
)
widths = {len(values) for values in vectors.values()}
if len(widths) != 1:
raise ValueError("PNCH012 tangent vectors must have equal lengths.")
width = len(vectors["state_mass"])
invalid_reason: str | None = None
if not all(isfinite(value) for values in vectors.values() for value in values):
invalid_reason = "non_finite_tangent_input"
raw_volume = (
self.cvol0
+ sum(self.external_volumes.values())
+ self.connected_external_volume()
)
minimum_volume = self.cvol0 / 100.0
volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18)
on_volume_boundary = (
abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale
)
supplied_volume_tangent = vectors["volume"]
if raw_volume < minimum_volume or on_volume_boundary:
used_volume_tangent = (0.0,) * width
used_volume_rate_tangent = (0.0,) * width
if on_volume_boundary and any(
value != 0.0
for value in (
*supplied_volume_tangent,
*vectors["volume_rate"],
)
):
invalid_reason = invalid_reason or "volume_floor_boundary"
else:
used_volume_tangent = supplied_volume_tangent
used_volume_rate_tangent = vectors["volume_rate"]
properties = property_linearization or self.medium.linearize_properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
vectors["state_mass"],
vectors["state_energy"],
used_volume_tangent,
)
if properties.tangents.width != width:
raise ValueError(
"PNCH012 property tangent width must match balance tangents."
)
props = properties.properties
if not properties.valid:
invalid_reason = invalid_reason or properties.reason
mass_derivative = sum(
self.get_port(port_name).m_flow for port_name in port_names
)
volume_rate = self.total_volume_rate()
energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate
mass_tangent = [0.0] * width
energy_tangent = [
-self.kth * self.sth * properties.tangents.T[index]
- volume_rate * properties.tangents.p[index]
- props.p * used_volume_rate_tangent[index]
for index in range(width)
]
for port_name in port_names:
port = self.get_port(port_name)
flow_tangent = vectors[f"flow:{port_name}"]
if (
abs(port.m_flow) <= flow_boundary_tolerance
and any(value != 0.0 for value in flow_tangent)
):
invalid_reason = invalid_reason or (
f"flow_direction_boundary:{port_name}"
)
if port.m_flow > 0.0:
inlet_h = connected_h[port_name]
inlet_h_tangent = vectors[f"enthalpy:{port_name}"]
else:
inlet_h = props.h
inlet_h_tangent = properties.tangents.h
energy_derivative += port.m_flow * inlet_h
for index in range(width):
mass_tangent[index] += flow_tangent[index]
energy_tangent[index] += (
inlet_h * flow_tangent[index]
+ port.m_flow * inlet_h_tangent[index]
)
return Pnch012DerivativeLinearization(
derivative=(mass_derivative, energy_derivative),
tangents=(tuple(mass_tangent), tuple(energy_tangent)),
properties=properties,
valid=invalid_reason is None,
reason=invalid_reason,
)
def pressure_flow_equation_values(self) -> tuple[float, ...]: def pressure_flow_equation_values(self) -> tuple[float, ...]:
pressure = self.medium.properties_from_mU( pressure = self.medium.properties_from_mU(
self.state.m, self.state.m,
+2 -2
View File
@@ -1,7 +1,7 @@
# 元件建模规范与示例 # 元件建模规范与示例
规范的权威版本位于 规范的权威版本位于
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。 [`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次 本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
修改中同步,不能让示例形成另一套规则。 修改中同步,不能让示例形成另一套规则。
@@ -279,4 +279,4 @@ models=(
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。 10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
组件库、分类和自动发现的完整规则参见 组件库、分类和自动发现的完整规则参见
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。 [`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。
+141 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol from math import isfinite
from typing import Protocol, Sequence
from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.performance import profile_property from app.simulation.performance import profile_property
@@ -16,6 +17,36 @@ class ThermodynamicProperties:
h: float h: float
@dataclass(frozen=True)
class ThermodynamicPropertyTangents:
"""Directional derivatives of a recovered thermodynamic state."""
p: tuple[float, ...]
T: tuple[float, ...]
rho: tuple[float, ...]
u: tuple[float, ...]
h: tuple[float, ...]
@property
def width(self) -> int:
return len(self.p)
@classmethod
def zeros(cls, width: int) -> "ThermodynamicPropertyTangents":
values = (0.0,) * width
return cls(p=values, T=values, rho=values, u=values, h=values)
@dataclass(frozen=True)
class ThermodynamicPropertiesLinearization:
"""Primal properties and a validity-checked directional linearization."""
properties: ThermodynamicProperties
tangents: ThermodynamicPropertyTangents
valid: bool = True
reason: str | None = None
class GasMedium(Protocol): class GasMedium(Protocol):
"""Thermodynamic contract required by pneumatic components. """Thermodynamic contract required by pneumatic components.
@@ -75,6 +106,18 @@ class GasMedium(Protocol):
V: float, V: float,
) -> ThermodynamicProperties: ... ) -> ThermodynamicProperties: ...
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization: ...
@dataclass(frozen=True) @dataclass(frozen=True)
class IdealGasMedium: class IdealGasMedium:
@@ -224,3 +267,100 @@ class IdealGasMedium:
u = U / m u = U / m
h = self.specific_enthalpy(T) h = self.specific_enthalpy(T)
return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h)
def linearize_properties_from_mU(
self,
m: float,
U: float,
V: float,
dm: Sequence[float],
dU: Sequence[float],
dV: Sequence[float],
*,
properties: ThermodynamicProperties | None = None,
) -> ThermodynamicPropertiesLinearization:
"""Linearize properties_from_mU for several seed directions."""
dm_values = tuple(float(value) for value in dm)
dU_values = tuple(float(value) for value in dU)
dV_values = tuple(float(value) for value in dV)
if not (len(dm_values) == len(dU_values) == len(dV_values)):
raise ValueError("Thermodynamic tangent vectors must have equal lengths.")
props = properties or self.properties_from_mU(m, U, V)
width = len(dm_values)
expected_density = m / V
expected_internal_energy = U / m
if (
abs(props.rho - expected_density)
> 1.0e-12 * max(abs(expected_density), 1.0)
or abs(props.u - expected_internal_energy)
> 1.0e-12 * max(abs(expected_internal_energy), 1.0)
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="properties_primal_mismatch",
)
if not all(
isfinite(value)
for values in (dm_values, dU_values, dV_values)
for value in values
):
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_finite_tangent_input",
)
cv = self.cv_at_temperature(props.T)
cp = self.cp_at_temperature(props.T)
if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0:
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents.zeros(width),
valid=False,
reason="non_positive_heat_capacity",
)
drho: list[float] = []
du: list[float] = []
dT: list[float] = []
dp: list[float] = []
dh: list[float] = []
for mass_tangent, energy_tangent, volume_tangent in zip(
dm_values,
dU_values,
dV_values,
strict=True,
):
density_tangent = mass_tangent / V - m * volume_tangent / (V * V)
internal_energy_tangent = (
energy_tangent / m - U * mass_tangent / (m * m)
)
temperature_tangent = internal_energy_tangent / cv
pressure_tangent = self.R_gas * (
props.T * density_tangent + props.rho * temperature_tangent
)
enthalpy_tangent = cp * temperature_tangent
drho.append(density_tangent)
du.append(internal_energy_tangent)
dT.append(temperature_tangent)
dp.append(pressure_tangent)
dh.append(enthalpy_tangent)
tangent_values = (*drho, *du, *dT, *dp, *dh)
valid = all(isfinite(value) for value in tangent_values)
return ThermodynamicPropertiesLinearization(
properties=props,
tangents=ThermodynamicPropertyTangents(
p=tuple(dp),
T=tuple(dT),
rho=tuple(drho),
u=tuple(du),
h=tuple(dh),
),
valid=valid,
reason=None if valid else "non_finite_property_tangent",
)
File diff suppressed because it is too large. Load diff
+148 -14
View File
@@ -12,6 +12,7 @@ CancellationCheck = Callable[[], bool]
AcceptedStepCallback = Callable[[float], None] AcceptedStepCallback = Callable[[float], None]
IntegrationStatus = Literal["completed", "cancelled", "failed"] IntegrationStatus = Literal["completed", "cancelled", "failed"]
DenseState = Callable[[float], list[float]] DenseState = Callable[[float], list[float]]
JacobianCallable = Callable[[float, object], object]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -30,7 +31,9 @@ StateTransitionHandler = Callable[
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64 _MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
class _IntegrationCancelled(Exception): class IntegrationCancelled(Exception):
"""Internal control-flow signal shared by RHS and Jacobian evaluation."""
pass pass
@@ -59,9 +62,19 @@ class SolverSegmentDiagnostics:
solver_start_count: int = 0 solver_start_count: int = 0
state_transition_count: int = 0 state_transition_count: int = 0
recoverable_retry_count: int = 0 recoverable_retry_count: int = 0
jacobian_evaluation_count: int = 0
jacobian_full_build_count: int = 0
jacobian_secant_reuse_count: int = 0
jacobian_audit_failure_count: int = 0
finite_difference_rhs_evaluation_count: int = 0
jacobian_base_rhs_evaluation_count: int = 0
jacobian_jv_audit_rhs_evaluation_count: int = 0
exact_column_build_count: int = 0
exact_column_fallback_count: int = 0
jacobian_assembly_seconds: float = 0.0
def as_dict(self) -> dict[str, float | int]: def as_dict(self) -> dict[str, float | int]:
return { result: dict[str, float | int] = {
"startTime": self.start_time, "startTime": self.start_time,
"requestedStopTime": self.requested_stop_time, "requestedStopTime": self.requested_stop_time,
"simulatedUntil": self.simulated_until, "simulatedUntil": self.simulated_until,
@@ -73,6 +86,61 @@ class SolverSegmentDiagnostics:
"stateTransitionCount": self.state_transition_count, "stateTransitionCount": self.state_transition_count,
"recoverableRetryCount": self.recoverable_retry_count, "recoverableRetryCount": self.recoverable_retry_count,
} }
if (
self.jacobian_evaluation_count
or self.finite_difference_rhs_evaluation_count
or self.jacobian_assembly_seconds
):
result.update(
{
"jacobianEvaluationCount": self.jacobian_evaluation_count,
"jacobianFullBuildCount": self.jacobian_full_build_count,
"jacobianSecantReuseCount": self.jacobian_secant_reuse_count,
"jacobianAuditFailureCount": self.jacobian_audit_failure_count,
"finiteDifferenceRhsEvaluationCount": (
self.finite_difference_rhs_evaluation_count
),
"jacobianBaseRhsEvaluationCount": (
self.jacobian_base_rhs_evaluation_count
),
"jacobianJvAuditRhsEvaluationCount": (
self.jacobian_jv_audit_rhs_evaluation_count
),
"exactColumnBuildCount": self.exact_column_build_count,
"exactColumnFallbackCount": (
self.exact_column_fallback_count
),
"jacobianAssemblySeconds": self.jacobian_assembly_seconds,
}
)
return result
_JACOBIAN_DIAGNOSTIC_KEYS = (
"jacobianEvaluationCount",
"fullBuildCount",
"secantReuseCount",
"auditFailureCount",
"finiteDifferenceRhsEvaluationCount",
"baseRhsEvaluationCount",
"jvAuditEvaluationCount",
"exactColumnBuildCount",
"exactColumnFallbackCount",
"assemblySeconds",
)
def _jacobian_diagnostic_snapshot(
jac: JacobianCallable | None,
) -> dict[str, float]:
diagnostics = getattr(jac, "diagnostics", None)
if diagnostics is None:
return {key: 0.0 for key in _JACOBIAN_DIAGNOSTIC_KEYS}
values = diagnostics()
return {
key: float(values.get(key, 0.0))
for key in _JACOBIAN_DIAGNOSTIC_KEYS
}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -309,7 +377,7 @@ def _runge_kutta_4(
for target_time in t_eval[1:]: for target_time in t_eval[1:]:
while current_time < target_time: while current_time < target_time:
if cancel_check is not None and cancel_check(): if cancel_check is not None and cancel_check():
raise _IntegrationCancelled raise IntegrationCancelled
dt = min(config.max_step, target_time - current_time) dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state) k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
@@ -374,7 +442,7 @@ def _runge_kutta_4(
report_step(current_time) report_step(current_time)
_append_solution_sample(times, states, target_time, state) _append_solution_sample(times, states, target_time, state)
except _IntegrationCancelled: except IntegrationCancelled:
status = "cancelled" status = "cancelled"
message = "Simulation was stopped before reaching the requested end time." message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state) _append_solution_sample(times, states, current_time, state)
@@ -451,7 +519,7 @@ def _runge_kutta_4_segmented(
nonlocal current_time, last_transition, same_time_transition_count, state nonlocal current_time, last_transition, same_time_transition_count, state
while current_time < target_time: while current_time < target_time:
if cancel_check is not None and cancel_check(): if cancel_check is not None and cancel_check():
raise _IntegrationCancelled raise IntegrationCancelled
dt = min(config.max_step, target_time - current_time) dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state) k1 = rhs(current_time, state)
k2 = rhs( k2 = rhs(
@@ -565,7 +633,7 @@ def _runge_kutta_4_segmented(
sample_time = float(sample_times[sample_index]) sample_time = float(sample_times[sample_index])
_append_solution_sample(times, states, sample_time, state) _append_solution_sample(times, states, sample_time, state)
sample_index += 1 sample_index += 1
except _IntegrationCancelled: except IntegrationCancelled:
status = "cancelled" status = "cancelled"
message = "Simulation was stopped before reaching the requested end time." message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state) _append_solution_sample(times, states, current_time, state)
@@ -595,6 +663,7 @@ def _integrate_scipy_stepwise(
breakpoints: Sequence[float] = (), breakpoints: Sequence[float] = (),
state_transition_handler: StateTransitionHandler | None = None, state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None, jac_sparsity=None,
jac: JacobianCallable | None = None,
) -> ODESolution: ) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets. """Initial stepwise integration path for breakpoints and state resets.
@@ -617,6 +686,7 @@ def _integrate_scipy_stepwise(
solver_type = solver_types.get(config.method) solver_type = solver_types.get(config.method)
if solver_type is None: if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}") raise ValueError(f"Unsupported integration method: {config.method}")
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
times = [float(config.t_start)] times = [float(config.t_start)]
states = [[float(value)] for value in initial_state] states = [[float(value)] for value in initial_state]
@@ -632,8 +702,13 @@ def _integrate_scipy_stepwise(
def cancellable_rhs(time, state): def cancellable_rhs(time, state):
if cancel_check(): if cancel_check():
raise _IntegrationCancelled raise IntegrationCancelled
return rhs(float(time), [float(value) for value in state]) normalized_state = [float(value) for value in state]
derivative = rhs(float(time), normalized_state)
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
observer(float(time), normalized_state, derivative)
return derivative
status: IntegrationStatus = "completed" status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval." message = "The solver successfully reached the end of the integration interval."
@@ -683,6 +758,7 @@ def _integrate_scipy_stepwise(
segment_solver_starts = 0 segment_solver_starts = 0
segment_state_transitions = 0 segment_state_transitions = 0
segment_recoverable_retries = 0 segment_recoverable_retries = 0
jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac)
while has_integration_interval and last_accepted_time < integration_end: while has_integration_interval and last_accepted_time < integration_end:
if cancel_check(): if cancel_check():
@@ -695,8 +771,11 @@ def _integrate_scipy_stepwise(
"atol": config.atol, "atol": config.atol,
"max_step": segment_max_step, "max_step": segment_max_step,
} }
if jac_sparsity is not None and config.method in {"BDF", "Radau"}: if config.method in {"BDF", "Radau"}:
solver_options["jac_sparsity"] = jac_sparsity if implicit_jac is not None:
solver_options["jac"] = implicit_jac
elif jac_sparsity is not None:
solver_options["jac_sparsity"] = jac_sparsity
requested_first_step = ( requested_first_step = (
0.1 * segment_max_step 0.1 * segment_max_step
if last_recoverable_error is not None if last_recoverable_error is not None
@@ -709,6 +788,9 @@ def _integrate_scipy_stepwise(
) )
try: try:
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
solver = solver_type( solver = solver_type(
cancellable_rhs, cancellable_rhs,
last_accepted_time, last_accepted_time,
@@ -716,7 +798,7 @@ def _integrate_scipy_stepwise(
integration_end, integration_end,
**solver_options, **solver_options,
) )
except _IntegrationCancelled: except IntegrationCancelled:
status = "cancelled" status = "cancelled"
message = cancellation_message() message = cancellation_message()
break break
@@ -755,7 +837,7 @@ def _integrate_scipy_stepwise(
step_start_state = list(last_accepted_state) step_start_state = list(last_accepted_state)
try: try:
step_message = solver.step() step_message = solver.step()
except _IntegrationCancelled: except IntegrationCancelled:
status = "cancelled" status = "cancelled"
message = ( message = (
"Simulation was stopped before reaching the requested end time." "Simulation was stopped before reaching the requested end time."
@@ -957,6 +1039,11 @@ def _integrate_scipy_stepwise(
if not restart_at_transition: if not restart_at_transition:
break break
jacobian_work_end = _jacobian_diagnostic_snapshot(implicit_jac)
jacobian_work = {
key: jacobian_work_end[key] - jacobian_work_start[key]
for key in _JACOBIAN_DIAGNOSTIC_KEYS
}
solver_segments.append( solver_segments.append(
SolverSegmentDiagnostics( SolverSegmentDiagnostics(
start_time=float(segment_start_time), start_time=float(segment_start_time),
@@ -971,6 +1058,34 @@ def _integrate_scipy_stepwise(
solver_start_count=segment_solver_starts, solver_start_count=segment_solver_starts,
state_transition_count=segment_state_transitions, state_transition_count=segment_state_transitions,
recoverable_retry_count=segment_recoverable_retries, recoverable_retry_count=segment_recoverable_retries,
jacobian_evaluation_count=int(
jacobian_work["jacobianEvaluationCount"]
),
jacobian_full_build_count=int(
jacobian_work["fullBuildCount"]
),
jacobian_secant_reuse_count=int(
jacobian_work["secantReuseCount"]
),
jacobian_audit_failure_count=int(
jacobian_work["auditFailureCount"]
),
finite_difference_rhs_evaluation_count=int(
jacobian_work["finiteDifferenceRhsEvaluationCount"]
),
jacobian_base_rhs_evaluation_count=int(
jacobian_work["baseRhsEvaluationCount"]
),
jacobian_jv_audit_rhs_evaluation_count=int(
jacobian_work["jvAuditEvaluationCount"]
),
exact_column_build_count=int(
jacobian_work["exactColumnBuildCount"]
),
exact_column_fallback_count=int(
jacobian_work["exactColumnFallbackCount"]
),
jacobian_assembly_seconds=jacobian_work["assemblySeconds"],
) )
) )
if status != "completed": if status != "completed":
@@ -1034,6 +1149,7 @@ def integrate_ode(
breakpoints: Sequence[float] | None = None, breakpoints: Sequence[float] | None = None,
state_transition_handler: StateTransitionHandler | None = None, state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None, jac_sparsity=None,
jac: JacobianCallable | None = None,
): ):
"""Integrate an ODE, optionally restarting at equation discontinuities. """Integrate an ODE, optionally restarting at equation discontinuities.
@@ -1104,10 +1220,26 @@ def integrate_ode(
normalized_breakpoints, normalized_breakpoints,
state_transition_handler, state_transition_handler,
jac_sparsity, jac_sparsity,
jac,
) )
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solve_rhs = rhs
if implicit_jac is not None:
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
def observed_rhs(time, state):
derivative = rhs(time, state)
observer(float(time), state, derivative)
return derivative
solve_rhs = observed_rhs
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
solve_options = { solve_options = {
"fun": rhs, "fun": solve_rhs,
"t_span": (config.t_start, config.t_stop), "t_span": (config.t_start, config.t_stop),
"y0": initial_state, "y0": initial_state,
"method": config.method, "method": config.method,
@@ -1118,6 +1250,8 @@ def integrate_ode(
} }
if config.first_step is not None: if config.first_step is not None:
solve_options["first_step"] = config.first_step solve_options["first_step"] = config.first_step
if jac_sparsity is not None and config.method in {"BDF", "Radau"}: if implicit_jac is not None:
solve_options["jac"] = implicit_jac
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
solve_options["jac_sparsity"] = jac_sparsity solve_options["jac_sparsity"] = jac_sparsity
return solve_ivp(**solve_options) return solve_ivp(**solve_options)
+850
View File
@@ -0,0 +1,850 @@
"""Proof-gated tangent columns for the three-piston reference network.
This module is deliberately narrower than the generic algebraic solver. It
only compiles a tangent provider after proving the state layout, component
types, physical connections, and causal execution plan used by the committed
three-piston XML. A failed proof leaves the ordinary seed-0 numerical
Jacobian in control; a runtime mode boundary requests the same one-build
fallback through :class:`ExactColumnsUnavailable`.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
from typing import TYPE_CHECKING
import numpy as np
from app.simulation.solvers.jacobian import ExactColumnsUnavailable
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.network import Endpoint
if TYPE_CHECKING:
from app.simulation.systems.generic import GenericFluidSystem
_TARGET_BRANCH_NAMES = (
(
"mass_friction_endstops_10",
"pn_brp2_8",
"pn_c1_8",
"pneumatic_69",
"elasticendstop_8",
),
(
"mass_friction_endstops_11",
"pn_brp2_9",
"pn_c1_9",
"pneumatic_68",
"elasticendstop_9",
),
(
"mass_friction_endstops_12",
"pn_brp2_10",
"pn_c1_10",
"pneumatic_66",
"elasticendstop_10",
),
)
@dataclass(frozen=True)
class ThreePistonBranch:
mass: object
piston: object
chamber: object
pipe: object
contact: object
velocity_index: int
position_index: int
@dataclass(frozen=True)
class ThreePistonTangentCompilation:
eligible: bool
reason: str | None
columns: tuple[int, ...] = ()
provider: "ThreePistonTangentProvider | None" = None
reached_assignment_count: int = 0
def diagnostics(self) -> dict[str, object]:
return {
"eligible": self.eligible,
"fallbackReason": self.reason,
"columns": list(self.columns),
"columnCount": len(self.columns),
"reachedAssignmentCount": self.reached_assignment_count,
}
@dataclass(frozen=True)
class _PrimalContext:
time: float
state: np.ndarray
connected_h: dict[str, dict[str, float]]
def _failed(reason: str) -> ThreePistonTangentCompilation:
return ThreePistonTangentCompilation(False, reason)
class ThreePistonTangentProvider:
"""Batched six-direction provider compiled for one system instance."""
def __init__(
self,
system: "GenericFluidSystem",
branches: tuple[ThreePistonBranch, ...],
columns: tuple[int, ...],
state_offsets: Mapping[str, tuple[int, int]],
reached_assignment_count: int,
) -> None:
self.system = system
self.branches = branches
self.columns = columns
self.state_offsets = dict(state_offsets)
self.reached_assignment_count = reached_assignment_count
self._context: _PrimalContext | None = None
self._capture_requested = False
def request_primal_capture(self) -> None:
"""Capture exactly the next completed RHS primal closure."""
self._capture_requested = True
def cancel_primal_capture(self) -> None:
"""Discard a pending capture after an interrupted base RHS."""
self._capture_requested = False
def record_primal(
self,
time: float,
state: Sequence[float],
connected_h: Mapping[str, Mapping[str, float]],
) -> None:
if not self._capture_requested:
return
self._capture_requested = False
required_components = {
item.name
for branch in self.branches
for item in (branch.chamber, branch.pipe)
}
self._context = _PrimalContext(
time=float(time),
state=np.asarray(state, dtype=float).copy(),
connected_h={
name: {port: float(value) for port, value in values.items()}
for name, values in connected_h.items()
if name in required_components
},
)
def __call__(
self,
time: float,
state: np.ndarray,
normalized_indexes: tuple[int, ...],
) -> np.ndarray:
if normalized_indexes != self.columns:
raise ValueError("Compiled tangent columns were requested out of order.")
context = self._context
values = np.asarray(state, dtype=float)
if (
context is None
or float(time) != context.time
or values.shape != context.state.shape
or not np.array_equal(values, context.state)
):
raise ExactColumnsUnavailable("stalePrimalContext")
return self._evaluate(context)
@staticmethod
def _zeros(width: int) -> tuple[float, ...]:
return (0.0,) * width
@staticmethod
def _has_signal(vector: Sequence[float]) -> bool:
return any(float(value) != 0.0 for value in vector)
@staticmethod
def _require_valid(value: object, prefix: str) -> None:
if not bool(getattr(value, "valid", False)):
reason = getattr(value, "reason", None) or "invalid"
raise ExactColumnsUnavailable(f"{prefix}:{reason}")
def _evaluate(self, context: _PrimalContext) -> np.ndarray:
# The primal RHS may disable the causal fast path after a residual
# audit. Recheck after that closure and before replaying its compiled
# assignments so a dynamic downgrade uses the ordinary full FD build.
if not self.system.pressure_flow_solver.causal_fast_path_enabled:
raise ExactColumnsUnavailable("causalFastPathDisabled")
width = len(self.columns)
zero = self._zeros(width)
out = np.zeros((len(context.state), width), dtype=float)
seeds = {
column: tuple(float(index == seed_index) for index in range(width))
for seed_index, column in enumerate(self.columns)
}
solver = self.system.pressure_flow_solver
tangents: dict[str, tuple[float, ...]] = {}
def tangent(key: str) -> tuple[float, ...]:
return tangents.get(key, zero)
def negative_sum(
vectors: Sequence[Sequence[float]],
) -> tuple[float, ...]:
return tuple(
-sum(float(vector[index]) for vector in vectors)
for index in range(width)
)
for variable in ("x", "v"):
plan = solver._causal_effort_plan_by_variable.get(variable)
if plan is None:
raise ExactColumnsUnavailable("causalEffortPlanUnavailable")
for assignment in plan:
matched = [
branch
for branch in self.branches
if any(
unknown.component == branch.mass.name
for unknown in assignment.members
)
]
if len(matched) > 1:
raise ExactColumnsUnavailable("coupledTargetMechanicalSeeds")
vector = zero
if matched:
branch = matched[0]
column = (
branch.position_index
if variable == "x"
else branch.velocity_index
)
vector = seeds[column]
for member in assignment.members:
tangents[member.id] = vector
geometry: dict[str, object] = {}
chamber_properties: dict[str, object] = {}
for branch in self.branches:
piston = branch.piston
linearization = piston.linearize_geometry_and_force(
tangent(f"{piston.name}.port_4.x"),
tangent(f"{piston.name}.port_5.x"),
tangent(f"{piston.name}.port_4.v"),
tangent(f"{piston.name}.port_5.v"),
zero,
)
self._require_valid(linearization, "pistonGeometry")
chamber = branch.chamber
volume = float(chamber.total_volume())
if volume <= float(chamber.cvol0) / 100.0:
raise ExactColumnsUnavailable("volumeFloor")
primal = chamber.medium.properties_from_mU(
chamber.state.m,
chamber.state.U,
volume,
)
properties = chamber.medium.linearize_properties_from_mU(
chamber.state.m,
chamber.state.U,
volume,
zero,
zero,
linearization.volume_tangent,
properties=primal,
)
self._require_valid(properties, "chamberProperties")
geometry[piston.name] = linearization
chamber_properties[chamber.name] = properties
pressure_plan = solver._causal_effort_plan_by_variable.get("p")
if pressure_plan is None:
raise ExactColumnsUnavailable("causalPressurePlanUnavailable")
for assignment in pressure_plan:
matched = [
branch
for branch in self.branches
if any(
unknown.component == branch.chamber.name
for unknown in assignment.members
)
]
if len(matched) > 1:
raise ExactColumnsUnavailable("coupledTargetPressureSeeds")
vector = (
chamber_properties[matched[0].chamber.name].tangents.p
if matched
else zero
)
for member in assignment.members:
tangents[member.id] = tuple(vector)
contacts: dict[str, object] = {}
for branch in self.branches:
piston = branch.piston
linearization = piston.linearize_geometry_and_force(
tangent(f"{piston.name}.port_4.x"),
tangent(f"{piston.name}.port_5.x"),
tangent(f"{piston.name}.port_4.v"),
tangent(f"{piston.name}.port_5.v"),
tangent(f"{piston.name}.port_1.p"),
)
self._require_valid(linearization, "pistonPressureForce")
geometry[piston.name] = linearization
contact = branch.contact
contact_linearization = contact.linearize_contact_force(
tangent(f"{contact.name}.port_1.x"),
tangent(f"{contact.name}.port_2.x"),
tangent(f"{contact.name}.port_1.v"),
tangent(f"{contact.name}.port_2.v"),
)
self._require_valid(contact_linearization, "contactMode")
contacts[contact.name] = contact_linearization
equations = {
equation.id: equation for equation in solver.equation_templates
}
branch_component = {
item.name: branch
for branch in self.branches
for item in (
branch.mass,
branch.piston,
branch.chamber,
branch.pipe,
branch.contact,
)
}
pipe_flows: dict[str, object] = {}
for stage in solver._explicit_flow_plan:
pending: list[tuple[str, tuple[float, ...]]] = []
for assignment in stage.assignments:
equation = equations.get(assignment.equation_id)
if equation is None:
raise ExactColumnsUnavailable("causalEquationMissing")
dependencies = [
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
]
if not any(
self._has_signal(vector) for vector in dependencies
):
pending.append((assignment.unknown.id, zero))
continue
if equation.relation == "sumToZero":
vectors = [
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
and variable in solver._unknowns_by_id
and solver._unknowns_by_id[variable].role == "flow"
]
pending.append(
(assignment.unknown.id, negative_sum(vectors))
)
continue
component = assignment.component
model = getattr(component, "MODEL_TYPE", None)
if model == "amesim_pnl0001":
branch = branch_component.get(component.name)
if branch is None or component is not branch.pipe:
raise ExactColumnsUnavailable("unexpectedPipeReach")
flow_linearization = pipe_flows.get(component.name)
if flow_linearization is None:
properties = component.medium.properties_from_mU(
component.state.m,
component.state.U,
component.volume,
)
flow_linearization = component.linearize_mass_flow(
component.port_1.p,
component.port_2.p,
properties.T,
)
self._require_valid(
flow_linearization,
"pipeMassFlow",
)
pipe_flows[component.name] = flow_linearization
vector = tuple(
flow_linearization.partial_p_1 * first
+ flow_linearization.partial_p_2 * second
for first, second in zip(
tangent(f"{component.name}.port_1.p"),
tangent(f"{component.name}.port_2.p"),
strict=True,
)
)
elif model == "amesim_lstp00a":
contact_linearization = contacts.get(component.name)
if contact_linearization is None:
raise ExactColumnsUnavailable(
f"unexpectedContactReach:{component.name}"
)
sign = (
1.0
if assignment.unknown.port == "port_1"
else -1.0
)
vector = tuple(
sign * value
for value in contact_linearization.force_tangent
)
elif model == "amesim_pnrp17":
piston_geometry = geometry.get(component.name)
if piston_geometry is None:
raise ExactColumnsUnavailable(
f"unexpectedPistonReach:{component.name}"
)
other = next(
(
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
and variable.endswith(".f")
),
zero,
)
sign = (
-1.0
if equation.id.endswith(
"piston_side_force_balance"
)
else 1.0
)
vector = tuple(
-force + sign * pressure
for force, pressure in zip(
other,
piston_geometry.pressure_force_tangent,
strict=True,
)
)
else:
raise ExactColumnsUnavailable(
f"unsupportedReach:{model or 'connection'}"
)
pending.append((assignment.unknown.id, tuple(vector)))
for key, vector in pending:
tangents[key] = vector
outflow: dict[Endpoint, tuple[float, ...]] = {}
for branch in self.branches:
enthalpy_tangent = tuple(
chamber_properties[branch.chamber.name].tangents.h
)
for port_name in branch.chamber.ports:
outflow[Endpoint(branch.chamber.name, port_name)] = (
enthalpy_tangent
)
# PNRP17 mirrors the connected chamber enthalpy on its sole
# pneumatic port at the already closed primal point.
outflow[Endpoint(branch.piston.name, "port_1")] = enthalpy_tangent
connected_tangent: dict[
str,
dict[str, tuple[float, ...]],
] = {name: {} for name in self.system.network.components}
for connection in self.system.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
connected_tangent[first.component][first.port] = outflow.get(
second,
zero,
)
connected_tangent[second.component][second.port] = outflow.get(
first,
zero,
)
for component_name, port_values in connected_tangent.items():
component = self.system.network.components[component_name]
if not getattr(component, "PRESSURE_FLOW_DEPENDS_ON_STREAM", False):
continue
if any(
self._has_signal(vector) for vector in port_values.values()
):
raise ExactColumnsUnavailable(
f"streamSensitiveReach:{component_name}"
)
for branch in self.branches:
chamber = branch.chamber
chamber_linearization = chamber.linearize_state_derivative(
context.connected_h[chamber.name],
state_mass_tangent=zero,
state_energy_tangent=zero,
external_volume_tangent=(
geometry[branch.piston.name].volume_tangent
),
external_volume_rate_tangent=(
geometry[branch.piston.name].volume_flow_tangent
),
port_mass_flow_tangents={
name: tangent(f"{chamber.name}.{name}.m_flow")
for name in chamber.ports
},
connected_h_tangents=connected_tangent[chamber.name],
property_linearization=chamber_properties[chamber.name],
)
self._require_valid(chamber_linearization, "chamberDerivative")
offset, size = self.state_offsets[chamber.name]
if size != 2:
raise ValueError("Target chamber state layout changed.")
out[offset, :], out[offset + 1, :] = (
chamber_linearization.tangents
)
pipe = branch.pipe
pipe_properties = pipe.medium.linearize_properties_from_mU(
pipe.state.m,
pipe.state.U,
pipe.volume,
zero,
zero,
zero,
)
self._require_valid(pipe_properties, "pipeProperties")
pipe_linearization = pipe.linearize_state_derivative(
context.connected_h[pipe.name],
state_mass_tangent=zero,
state_energy_tangent=zero,
port_mass_flow_tangents={
name: tangent(f"{pipe.name}.{name}.m_flow")
for name in pipe.ports
},
connected_h_tangents=connected_tangent[pipe.name],
property_linearization=pipe_properties,
)
self._require_valid(pipe_linearization, "pipeDerivative")
offset, size = self.state_offsets[pipe.name]
if size != 2:
raise ValueError("Target pipe state layout changed.")
out[offset, :], out[offset + 1, :] = pipe_linearization.tangents
target_pneumatic = {
item.name
for branch in self.branches
for item in (branch.chamber, branch.pipe)
}
for entry in self.system.mechanical_state_reducer.state_entries:
if (
isinstance(entry, MechanicalConstraintGroup)
or entry.name in target_pneumatic
):
continue
for port_name, port in entry.ports.items():
definition = port.definition
if (
definition is not None
and definition.kind == "physical"
and definition.domain == "pneumatic"
and self._has_signal(
tangent(f"{entry.name}.{port_name}.m_flow")
)
):
raise ExactColumnsUnavailable(
f"unsupportedDynamicReach:{entry.name}"
)
mass_seeds = {
branch.mass.name: (
seeds[branch.velocity_index],
seeds[branch.position_index],
)
for branch in self.branches
}
for group in self.system.mechanical_state_reducer.groups:
if len(group.components) != 1:
raise ExactColumnsUnavailable("reachableRigidMassGroup")
mass = group.representative
force_1 = tangent(f"{mass.name}.port_1.f")
force_2 = tangent(f"{mass.name}.port_2.f")
velocity, position = mass_seeds.get(mass.name, (zero, zero))
if not any(
self._has_signal(vector)
for vector in (force_1, force_2, velocity, position)
):
continue
fixed = (
mass._constraint_acceleration == 0.0
and mass._constraint_velocity == 0.0
)
if fixed and (
abs(group.total_unconstrained_force())
<= 1.0e-12
* max(
abs(mass.port_1.f),
abs(mass.port_2.f),
1.0,
)
):
raise ExactColumnsUnavailable("mechanicalReleaseBoundary")
mass_linearization = mass.linearize_state_derivative(
force_1,
force_2,
velocity,
position,
constraint_mode="current" if fixed else "free",
)
self._require_valid(
mass_linearization,
"mechanicalDerivative",
)
offset, size = self.state_offsets[mass.name]
if size != 2:
raise ValueError("Mechanical state layout changed.")
out[offset, :], out[offset + 1, :] = mass_linearization.tangents
if not np.all(np.isfinite(out)):
raise ExactColumnsUnavailable("nonFiniteTangentColumns")
return out
def compile_three_piston_tangent_provider(
system: "GenericFluidSystem",
) -> ThreePistonTangentCompilation:
"""Compile the proof-gated target provider, or return a stable reason."""
solver = system.pressure_flow_solver
if not solver.causal_fast_path_eligible:
return _failed("causalFastPathIneligible")
if not solver.causal_fast_path_enabled:
return _failed("causalFastPathDisabled")
closure = system._thermofluid_closure_plan
if closure.uses_conservative_global_solver:
return _failed("conservativeThermofluidClosure")
if system.pneumatic_storage_reducer.groups:
return _failed("coupledPneumaticStorage")
state_offsets: dict[str, tuple[int, int]] = {}
cursor = 0
group_by_name: dict[str, MechanicalConstraintGroup] = {}
for entry in system.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
if len(entry.components) != 1:
cursor += 2
continue
component = entry.representative
state_offsets[component.name] = (cursor, 2)
group_by_name[component.name] = entry
cursor += 2
else:
state_offsets[entry.name] = (cursor, int(entry.state_size))
cursor += int(entry.state_size)
expected_types = (
"amesim_mecmas21",
"amesim_pnrp17",
"amesim_pnch012",
"amesim_pnl0001",
"amesim_lstp00a",
)
branches: list[ThreePistonBranch] = []
for names in _TARGET_BRANCH_NAMES:
try:
components = tuple(system.network.components[name] for name in names)
except KeyError:
return _failed("targetComponentMissing")
if tuple(getattr(item, "MODEL_TYPE", None) for item in components) != expected_types:
return _failed("targetComponentTypeMismatch")
mass, piston, chamber, pipe, contact = components
if mass.name not in group_by_name or mass.name not in state_offsets:
return _failed("targetMechanicalStateLayout")
offset, size = state_offsets[mass.name]
if size != 2:
return _failed("targetMechanicalStateLayout")
branches.append(
ThreePistonBranch(
mass=mass,
piston=piston,
chamber=chamber,
pipe=pipe,
contact=contact,
velocity_index=offset,
position_index=offset + 1,
)
)
required_pairs: set[frozenset[Endpoint]] = set()
for branch in branches:
required_pairs.update(
{
frozenset((Endpoint(branch.mass.name, "port_1"), Endpoint(branch.piston.name, "port_2"))),
frozenset((Endpoint(branch.piston.name, "port_1"), Endpoint(branch.chamber.name, "port_3"))),
frozenset((Endpoint(branch.chamber.name, "port_1"), Endpoint(branch.pipe.name, "port_1"))),
frozenset((Endpoint(branch.piston.name, "port_5"), Endpoint(branch.contact.name, "port_1"))),
}
)
actual_pairs = {
frozenset(connection.endpoints)
for connection in system.network.connections
if connection.kind == "physical"
}
if not required_pairs <= actual_pairs:
return _failed("targetTopologyMismatch")
required_methods = (
("piston", "linearize_geometry_and_force"),
("chamber", "linearize_state_derivative"),
("pipe", "linearize_mass_flow"),
("pipe", "linearize_state_derivative"),
("contact", "linearize_contact_force"),
("mass", "linearize_state_derivative"),
)
for branch in branches:
for owner, method in required_methods:
if not callable(getattr(getattr(branch, owner), method, None)):
return _failed(f"missingTangentPrimitive:{owner}.{method}")
if not callable(getattr(branch.chamber.medium, "linearize_properties_from_mU", None)):
return _failed("missingTangentPrimitive:medium.linearize_properties_from_mU")
if any(
len(group.components) != 1
for group in system.mechanical_state_reducer.groups
):
return _failed("rigidMassAggregation")
target_mass_names = {branch.mass.name for branch in branches}
target_chamber_names = {branch.chamber.name for branch in branches}
target_pipe_names = {branch.pipe.name for branch in branches}
target_piston_names = {branch.piston.name for branch in branches}
target_contact_names = {branch.contact.name for branch in branches}
reached_ids: set[str] = set()
for variable in ("x", "v"):
for assignment in solver._causal_effort_plan_by_variable.get(
variable,
(),
):
if any(
member.component in target_mass_names
for member in assignment.members
):
reached_ids.update(member.id for member in assignment.members)
for assignment in solver._causal_effort_plan_by_variable.get("p", ()):
if any(
member.component in target_chamber_names
for member in assignment.members
):
reached_ids.update(member.id for member in assignment.members)
equations = {
item.id: item for item in solver.equation_templates
}
reached_assignments = []
allowed_reached_models = {
"amesim_pnl0001",
"amesim_pnrp17",
"amesim_lstp00a",
}
for stage in solver._explicit_flow_plan:
stage_reached = []
for assignment in stage.assignments:
equation = equations.get(assignment.equation_id)
if equation is None:
return _failed("causalEquationMissing")
dependencies = set(equation.variables) - {
assignment.unknown.id
}
if not dependencies.intersection(reached_ids):
continue
component = assignment.component
if equation.relation != "sumToZero":
model_type = getattr(component, "MODEL_TYPE", None)
if model_type not in allowed_reached_models:
return _failed(f"unsupportedReach:{model_type}")
expected_names = {
"amesim_pnl0001": target_pipe_names,
"amesim_pnrp17": target_piston_names,
"amesim_lstp00a": target_contact_names,
}[model_type]
if component.name not in expected_names:
return _failed(
f"unexpectedReach:{model_type}:{component.name}"
)
if bool(
getattr(
component,
"PRESSURE_FLOW_DEPENDS_ON_STREAM",
False,
)
):
return _failed(f"streamSensitiveReach:{component.name}")
stage_reached.append(assignment)
reached_assignments.extend(stage_reached)
reached_ids.update(item.unknown.id for item in stage_reached)
# The only non-zero h_outflow seeds are the target PNCH ports. Each
# direct neighbour must consume it in a supported target balance, mirror
# it through the one-port piston, or terminate at a fixed PNPL01 cap.
# Secondary pressure blocks may contain the same causal flow coordinates,
# so membership alone is not evidence of a stream derivative. The direct
# enthalpy reach proof below, plus the runtime dynamic-owner gate, is the
# relevant condition for this target-specific program.
neighbor_by_endpoint: dict[Endpoint, Endpoint] = {}
for connection in system.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
neighbor_by_endpoint[first] = second
neighbor_by_endpoint[second] = first
permitted_h_neighbors = {
"amesim_pnl0001",
"amesim_pnrp17",
"amesim_pnpl01",
}
for branch in branches:
for port_name in branch.chamber.ports:
neighbor = neighbor_by_endpoint.get(
Endpoint(branch.chamber.name, port_name)
)
if neighbor is None:
return _failed("targetStreamBindingMissing")
component = system.network.components[neighbor.component]
if (
getattr(component, "MODEL_TYPE", None)
not in permitted_h_neighbors
):
return _failed(
f"unsupportedStreamReach:{component.name}"
)
if bool(
getattr(
component,
"PRESSURE_FLOW_DEPENDS_ON_STREAM",
False,
)
):
return _failed(f"streamSensitiveReach:{component.name}")
columns = tuple(
sorted(
index
for branch in branches
for index in (branch.velocity_index, branch.position_index)
)
)
provider = ThreePistonTangentProvider(
system,
tuple(branches),
columns,
state_offsets,
reached_assignment_count=len(reached_assignments),
)
return ThreePistonTangentCompilation(
True,
None,
columns,
provider,
reached_assignment_count=len(reached_assignments),
)
+316 -22
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from math import floor, isfinite from math import floor, isfinite
import os
from typing import Literal from typing import Literal
from app.simulation.core.base import Component, DynamicComponent from app.simulation.core.base import Component, DynamicComponent
@@ -12,6 +13,10 @@ from app.simulation.performance import performance_span, profile_phase
from app.simulation.property_cache import with_property_cache from app.simulation.property_cache import with_property_cache
from app.simulation.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
from app.simulation.solvers.jacobian import (
SparseJacobianCompatibilityError,
SparseSecantJacobian,
)
from app.simulation.solvers.mechanical import ( from app.simulation.solvers.mechanical import (
MechanicalConstraintGroup, MechanicalConstraintGroup,
MechanicalStateReducer, MechanicalStateReducer,
@@ -21,15 +26,58 @@ from app.simulation.solvers.pneumatic_storage import (
ideal_storage_group_is_reducible, ideal_storage_group_is_reducible,
) )
from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode from app.simulation.solvers.solver import (
IntegrationCancelled,
ODESolution,
SolveIVPConfig,
integrate_ode,
)
from app.simulation.solvers.signal import SignalResolver from app.simulation.solvers.signal import SignalResolver
from app.simulation.solvers.stream import StreamResolver from app.simulation.solvers.stream import StreamResolver
from app.simulation.solvers.tangent import (
ThreePistonTangentCompilation,
ThreePistonTangentProvider,
compile_three_piston_tangent_provider,
)
from app.simulation.systems.network import Endpoint, SimulationNetwork from app.simulation.systems.network import Endpoint, SimulationNetwork
SimulationProgressCallback = Callable[[float, str], None] SimulationProgressCallback = Callable[[float, str], None]
SimulationCancellationCheck = Callable[[], bool] SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"] SimulationRunStatus = Literal["completed", "cancelled", "failed"]
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE = "SIMULATION_ODE_JACOBIAN_MODE"
def _requested_ode_jacobian_mode() -> Literal[
"optimized",
"hybrid",
"semi-analytic",
"scipy",
]:
value = os.getenv(
ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE,
"scipy",
).strip().lower()
if value in {"optimized", "colored"}:
return "optimized"
if value in {"hybrid", "secant"}:
return "hybrid"
if value in {"semi-analytic", "semi_analytic", "analytic"}:
return "semi-analytic"
if value in {
"scipy",
"native",
"finite-difference",
"0",
"false",
"no",
"off",
}:
return "scipy"
raise ValueError(
f"{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE} must be "
"'optimized', 'hybrid', 'semi-analytic', or 'scipy'."
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -398,6 +446,7 @@ class GenericFluidSystem:
self.signal_propagation_count = 0 self.signal_propagation_count = 0
self.pneumatic_volume_propagation_count = 0 self.pneumatic_volume_propagation_count = 0
self._jacobian_sparsity = None self._jacobian_sparsity = None
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
def _request_causal_residual_audit(self) -> None: def _request_causal_residual_audit(self) -> None:
"""Make topology or mode boundaries verify the next causal closure.""" """Make topology or mode boundaries verify the next causal closure."""
@@ -874,6 +923,23 @@ class GenericFluidSystem:
"colorGroupCount": group_count, "colorGroupCount": group_count,
} }
def _exact_ode_jacobian_rows(self) -> dict[int, dict[int, float]]:
"""Return mode-independent kinematic rows safe to evaluate exactly."""
rows: dict[int, dict[int, float]] = {}
cursor = 0
for entry in self.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
# A discrete endstop can replace x' = v with x' = 0 for the
# active constrained mode. Keep those rows numerical; free
# mechanical groups always have d(x')/d(v) = 1.
if not entry.discrete_endstop_components:
rows[cursor + 1] = {cursor: 1.0}
cursor += 2
else:
cursor += entry.state_size
return rows
@profile_phase( @profile_phase(
"simulation.closure", "simulation.closure",
minimum_mode="audit", minimum_mode="audit",
@@ -1065,7 +1131,11 @@ class GenericFluidSystem:
def rhs(self, _time: float, state_vector: list[float]) -> list[float]: def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector) self.apply_state_vector(state_vector)
connected_h = self._close_current_state(_time) connected_h = self._close_current_state(_time)
return self._state_derivatives(connected_h) derivatives = self._state_derivatives(connected_h)
provider = self._ode_tangent_provider
if provider is not None:
provider.record_primal(_time, state_vector, connected_h)
return derivatives
def _append_current_state(self, series: dict[str, list[float]]) -> None: def _append_current_state(self, series: dict[str, list[float]]) -> None:
for component in self.network.components.values(): for component in self.network.components.values():
@@ -1156,29 +1226,112 @@ class GenericFluidSystem:
report_solver_time(time) report_solver_time(time)
return self.rhs(time, state_vector) return self.rhs(time, state_vector)
jacobian = None
jacobian_fallback_reason: str | None = None
tangent_compilation: ThreePistonTangentCompilation | None = None
selected_tangent_provider: ThreePistonTangentProvider | None = None
self._ode_tangent_provider = None
requested_jacobian_mode = (
_requested_ode_jacobian_mode()
if jac_sparsity is not None
else "scipy"
)
if (
jac_sparsity is not None
and requested_jacobian_mode
in {"optimized", "hybrid", "semi-analytic"}
):
state_count = int(jac_sparsity.shape[0])
if (
requested_jacobian_mode == "hybrid"
and not self.pressure_flow_solver.causal_fast_path_enabled
):
jacobian_fallback_reason = "causalAlgebraicExecutionUnavailable"
elif int(jac_sparsity.nnz) >= state_count * state_count:
jacobian_fallback_reason = "denseStateDependencyPattern"
else:
exact_columns = None
if requested_jacobian_mode == "semi-analytic":
tangent_compilation = (
compile_three_piston_tangent_provider(self)
)
if tangent_compilation.eligible:
provider = tangent_compilation.provider
if provider is None:
raise RuntimeError(
"An eligible tangent compilation has no provider."
)
selected_tangent_provider = provider
exact_columns = (
tangent_compilation.columns,
provider,
)
else:
jacobian_fallback_reason = (
f"semiAnalytic:{tangent_compilation.reason}"
)
if (
requested_jacobian_mode != "semi-analytic"
or tangent_compilation is not None
and tangent_compilation.eligible
):
def evaluate_jacobian_rhs(time, state):
if cancel_check is not None and cancel_check():
raise IntegrationCancelled
return monitored_rhs(
time,
[float(value) for value in state],
)
try:
jacobian = SparseSecantJacobian(
evaluate_jacobian_rhs,
jac_sparsity,
integration_config.atol,
exact_rows=self._exact_ode_jacobian_rows(),
exact_columns=exact_columns,
max_consecutive_reuses=(
1
if requested_jacobian_mode == "hybrid"
else 0
),
)
self._ode_tangent_provider = (
selected_tangent_provider
)
except SparseJacobianCompatibilityError as exc:
jacobian_fallback_reason = (
f"scipyCompatibility:{type(exc).__name__}"
)
def handle_state_transition(*args): def handle_state_transition(*args):
transition = self.mechanical_state_reducer.state_transition(*args) transition = self.mechanical_state_reducer.state_transition(*args)
if transition is not None: if transition is not None:
self._request_causal_residual_audit() self._request_causal_residual_audit()
return transition return transition
solution = integrate_ode( try:
rhs=monitored_rhs, solution = integrate_ode(
initial_state=initial_state, rhs=monitored_rhs,
config=integration_config, initial_state=initial_state,
t_eval=t_eval, config=integration_config,
cancel_check=cancel_check, t_eval=t_eval,
accepted_step_callback=( cancel_check=cancel_check,
report_solver_time if cancel_check is not None else None accepted_step_callback=(
), report_solver_time if cancel_check is not None else None
breakpoints=signal_event_times, ),
state_transition_handler=( breakpoints=signal_event_times,
handle_state_transition state_transition_handler=(
if self.mechanical_state_reducer.has_state_events handle_state_transition
else None if self.mechanical_state_reducer.has_state_events
), else None
jac_sparsity=jac_sparsity, ),
) jac_sparsity=jac_sparsity,
jac=jacobian,
)
finally:
self._ode_tangent_provider = None
if isinstance(solution, ODESolution): if isinstance(solution, ODESolution):
run_status: SimulationRunStatus = solution.status run_status: SimulationRunStatus = solution.status
integration_error = solution.error integration_error = solution.error
@@ -1212,6 +1365,44 @@ class GenericFluidSystem:
"recoverableRetryCount": 0, "recoverableRetryCount": 0,
} }
] ]
if jacobian is not None:
direct_jacobian = jacobian.diagnostics()
solver_segment_diagnostics[0].update(
{
"jacobianEvaluationCount": int(
direct_jacobian["jacobianEvaluationCount"]
),
"jacobianFullBuildCount": int(
direct_jacobian["fullBuildCount"]
),
"jacobianSecantReuseCount": int(
direct_jacobian["secantReuseCount"]
),
"jacobianAuditFailureCount": int(
direct_jacobian["auditFailureCount"]
),
"finiteDifferenceRhsEvaluationCount": int(
direct_jacobian[
"finiteDifferenceRhsEvaluationCount"
]
),
"jacobianBaseRhsEvaluationCount": int(
direct_jacobian["baseRhsEvaluationCount"]
),
"jacobianJvAuditRhsEvaluationCount": int(
direct_jacobian["jvAuditEvaluationCount"]
),
"exactColumnBuildCount": int(
direct_jacobian["exactColumnBuildCount"]
),
"exactColumnFallbackCount": int(
direct_jacobian["exactColumnFallbackCount"]
),
"jacobianAssemblySeconds": float(
direct_jacobian["assemblySeconds"]
),
}
)
solver_total_keys = ( solver_total_keys = (
"nfev", "nfev",
"njev", "njev",
@@ -1225,21 +1416,123 @@ class GenericFluidSystem:
key: sum(int(segment[key]) for segment in solver_segment_diagnostics) key: sum(int(segment[key]) for segment in solver_segment_diagnostics)
for key in solver_total_keys for key in solver_total_keys
} }
jacobian_work_keys = (
"jacobianEvaluationCount",
"jacobianFullBuildCount",
"jacobianSecantReuseCount",
"jacobianAuditFailureCount",
"finiteDifferenceRhsEvaluationCount",
"jacobianBaseRhsEvaluationCount",
"jacobianJvAuditRhsEvaluationCount",
"exactColumnBuildCount",
"exactColumnFallbackCount",
"jacobianAssemblySeconds",
)
for key in jacobian_work_keys:
if any(key in segment for segment in solver_segment_diagnostics):
solver_totals[key] = sum(
segment.get(key, 0)
for segment in solver_segment_diagnostics
)
jacobian_diagnostics = ( jacobian_diagnostics = (
self.jacobian_sparsity_diagnostics() self.jacobian_sparsity_diagnostics()
if integration_config.method in {"BDF", "Radau"} if integration_config.method in {"BDF", "Radau"}
else None else None
) )
runtime_jacobian_diagnostics: dict[str, object] | None = None
if jacobian_diagnostics is not None: if jacobian_diagnostics is not None:
color_group_count = int(jacobian_diagnostics["colorGroupCount"]) color_group_count = int(jacobian_diagnostics["colorGroupCount"])
for segment in solver_segment_diagnostics: if jacobian is None:
segment["finiteDifferenceRhsEstimate"] = ( for segment in solver_segment_diagnostics:
int(segment["njev"]) * color_group_count segment["finiteDifferenceRhsEstimate"] = (
int(segment["njev"]) * color_group_count
)
runtime_jacobian_diagnostics = {
"mode": "scipySparseFiniteDifference",
"fallbackReason": jacobian_fallback_reason,
"jacobianEvaluationCount": int(solver_totals["njev"]),
"fullBuildCount": int(solver_totals["njev"]),
"finiteDifferenceRhsEstimateIsExact": False,
}
else:
for segment in solver_segment_diagnostics:
segment["finiteDifferenceRhsEstimate"] = int(
segment.get("finiteDifferenceRhsEvaluationCount", 0)
) + int(
segment.get("jacobianJvAuditRhsEvaluationCount", 0)
)
runtime_jacobian_diagnostics = dict(jacobian.diagnostics())
runtime_jacobian_diagnostics.update(
{
"fallbackReason": jacobian_fallback_reason,
"finiteDifferenceRhsEstimateIsExact": True,
}
) )
if tangent_compilation is not None:
runtime_jacobian_diagnostics["tangentCompilation"] = (
tangent_compilation.diagnostics()
)
if tangent_compilation.eligible and jacobian is not None:
runtime_jacobian_diagnostics["mode"] = (
"semiAnalyticExactColumns"
)
exact_builds = int(
runtime_jacobian_diagnostics[
"exactColumnBuildCount"
]
)
exact_fallbacks = int(
runtime_jacobian_diagnostics[
"exactColumnFallbackCount"
]
)
if exact_builds == 0 and exact_fallbacks == 0:
effective_mode = "notEvaluated"
elif exact_builds == 0:
effective_mode = "numericalFallbackOnly"
elif exact_fallbacks:
effective_mode = "mixedExactAndNumericalFallback"
else:
effective_mode = "exactColumns"
runtime_jacobian_diagnostics["effectiveMode"] = (
effective_mode
)
if exact_fallbacks:
runtime_jacobian_diagnostics[
"runtimeFallbackReason"
] = runtime_jacobian_diagnostics[
"lastExactColumnFallbackReason"
]
solver_totals["finiteDifferenceRhsEstimate"] = sum( solver_totals["finiteDifferenceRhsEstimate"] = sum(
int(segment["finiteDifferenceRhsEstimate"]) int(segment["finiteDifferenceRhsEstimate"])
for segment in solver_segment_diagnostics for segment in solver_segment_diagnostics
) )
if jacobian is None:
solver_totals["jacobianRhsEvaluationCountEstimate"] = (
int(solver_totals["finiteDifferenceRhsEstimate"])
+ int(solver_totals["njev"])
)
else:
solver_totals["jacobianRhsEvaluationCount"] = (
int(
solver_totals.get(
"finiteDifferenceRhsEvaluationCount",
0,
)
)
+ int(
solver_totals.get(
"jacobianBaseRhsEvaluationCount",
0,
)
)
+ int(
solver_totals.get(
"jacobianJvAuditRhsEvaluationCount",
0,
)
)
)
with performance_span("simulation.postprocessing"): with performance_span("simulation.postprocessing"):
series: dict[str, list[float]] = {"time": []} series: dict[str, list[float]] = {"time": []}
@@ -1286,6 +1579,7 @@ class GenericFluidSystem:
"integration": { "integration": {
"method": integration_config.method, "method": integration_config.method,
"jacobianSparsity": jacobian_diagnostics, "jacobianSparsity": jacobian_diagnostics,
"jacobian": runtime_jacobian_diagnostics,
"segmentCount": len(solver_segment_diagnostics), "segmentCount": len(solver_segment_diagnostics),
"segments": solver_segment_diagnostics, "segments": solver_segment_diagnostics,
"totals": solver_totals, "totals": solver_totals,
+40
View File
@@ -0,0 +1,40 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
title SystemSimulationApp Launcher
set "BACKEND_SCRIPT=%~dp0start-backend.bat"
set "FRONTEND_SCRIPT=%~dp0start-reactflow.bat"
if not exist "%BACKEND_SCRIPT%" (
echo [ERROR] start-backend.bat was not found:
echo %BACKEND_SCRIPT%
pause
exit /b 1
)
if not exist "%FRONTEND_SCRIPT%" (
echo [ERROR] start-reactflow.bat was not found:
echo %FRONTEND_SCRIPT%
pause
exit /b 1
)
echo Starting FastAPI and ReactFlow in separate windows...
set "LAUNCH_ERROR=0"
ver >nul
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%BACKEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
ver >nul
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%FRONTEND_SCRIPT%"
if errorlevel 1 set "LAUNCH_ERROR=1"
if "%LAUNCH_ERROR%"=="1" (
echo.
echo [ERROR] One or more service windows could not be created.
pause
exit /b 1
)
exit /b 0
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -u
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
echo "[ERROR] start-all.sh requires Bash 4.3 or newer." >&2
exit 1
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
BACKEND_SCRIPT="$SCRIPT_DIR/start-backend.sh"
FRONTEND_SCRIPT="$SCRIPT_DIR/start-reactflow.sh"
if [[ ! -x "$BACKEND_SCRIPT" ]]; then
echo "[ERROR] Backend start script is missing or not executable:" >&2
echo " $BACKEND_SCRIPT" >&2
exit 1
fi
if [[ ! -x "$FRONTEND_SCRIPT" ]]; then
echo "[ERROR] Frontend start script is missing or not executable:" >&2
echo " $FRONTEND_SCRIPT" >&2
exit 1
fi
BACKEND_PID=""
FRONTEND_PID=""
signal_process_group() {
local signal="$1"
local pid="$2"
[[ -n "$pid" ]] || return 0
kill "-$signal" -- "-$pid" 2>/dev/null || kill "-$signal" "$pid" 2>/dev/null || true
}
process_group_is_running() {
local pid="$1"
[[ -n "$pid" ]] && kill -0 -- "-$pid" 2>/dev/null
}
cleanup() {
local backend_cleared=false
local force_kill=false
local frontend_cleared=false
local launcher_pid=$$
local timer_pid
trap '' INT TERM HUP
trap - EXIT
trap 'force_kill=true' ALRM
signal_process_group TERM "$BACKEND_PID"
signal_process_group TERM "$FRONTEND_PID"
(
sleep 5
kill -ALRM "$launcher_pid" 2>/dev/null || true
) &
timer_pid=$!
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ "$force_kill" == false && -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
while [[ "$force_kill" == false ]]; do
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == true && "$frontend_cleared" == true ]]; then
break
fi
sleep 0.1
done
kill -KILL "$timer_pid" 2>/dev/null || true
wait "$timer_pid" 2>/dev/null || true
if [[ "$force_kill" == true ]]; then
if [[ "$backend_cleared" == false ]] && ! process_group_is_running "$BACKEND_PID"; then
backend_cleared=true
fi
if [[ "$frontend_cleared" == false ]] && ! process_group_is_running "$FRONTEND_PID"; then
frontend_cleared=true
fi
if [[ "$backend_cleared" == false ]]; then
signal_process_group KILL "$BACKEND_PID"
fi
if [[ "$frontend_cleared" == false ]]; then
signal_process_group KILL "$FRONTEND_PID"
fi
fi
if [[ -n "$BACKEND_PID" ]]; then
wait "$BACKEND_PID" 2>/dev/null || true
fi
if [[ -n "$FRONTEND_PID" ]]; then
wait "$FRONTEND_PID" 2>/dev/null || true
fi
trap - ALRM
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
echo "Starting FastAPI and ReactFlow..."
echo "Press Ctrl+C to stop both services."
echo
set -m
"$BACKEND_SCRIPT" &
BACKEND_PID=$!
"$FRONTEND_SCRIPT" &
FRONTEND_PID=$!
set +m
wait -n
EXIT_CODE=$?
exit "$EXIT_CODE"
+17 -3
View File
@@ -1,15 +1,28 @@
@echo off @echo off
setlocal setlocal EnableExtensions DisableDelayedExpansion
cd /d "%~dp0" for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
title SystemSimulationApp FastAPI - 127.0.0.1:8000 title SystemSimulationApp FastAPI - 127.0.0.1:8000
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe" set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe"
if not exist "%PYTHON_EXE%" ( if not exist "%PYTHON_EXE%" (
echo [ERROR] Python virtual environment was not found: echo [ERROR] Python virtual environment was not found:
echo %PYTHON_EXE% echo %PYTHON_EXE%
echo. echo.
echo Create it and install the backend dependencies first:
echo py -3 -m venv "%REPO_ROOT%\.venv-win"
echo "%PYTHON_EXE%" -m pip install -r "%REPO_ROOT%\requirements.txt"
echo.
pause
exit /b 1
)
pushd "%REPO_ROOT%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the repository directory:
echo %REPO_ROOT%
echo.
pause pause
exit /b 1 exit /b 1
) )
@@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" (
pause pause
) )
popd
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
PYTHON_EXE="${SYSTEM_SIMULATION_PYTHON:-$REPO_ROOT/.venv/bin/python}"
if [[ "$PYTHON_EXE" != /* ]]; then
PYTHON_EXE="$REPO_ROOT/$PYTHON_EXE"
fi
if [[ ! -x "$PYTHON_EXE" ]]; then
echo "[ERROR] Python virtual environment was not found:" >&2
echo " $PYTHON_EXE" >&2
echo >&2
echo "Create it and install the backend dependencies first:" >&2
echo " python3 -m venv \"$REPO_ROOT/.venv\"" >&2
echo " \"$REPO_ROOT/.venv/bin/python\" -m pip install -r \"$REPO_ROOT/requirements.txt\"" >&2
exit 1
fi
cd "$REPO_ROOT"
echo "Starting FastAPI at http://127.0.0.1:8000"
echo "Press Ctrl+C to stop the service."
echo
exec "$PYTHON_EXE" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
+77
View File
@@ -0,0 +1,77 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI"
set "FRONTEND_DIR=%REPO_ROOT%\frontend"
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
if not exist "%FRONTEND_DIR%\package.json" (
echo [ERROR] Frontend package.json was not found:
echo %FRONTEND_DIR%\package.json
echo.
pause
exit /b 1
)
set "NODE_DIR="
for /d %%D in ("%REPO_ROOT%\.tools\node-*-win-x64") do (
if not defined NODE_DIR if exist "%%~fD\node.exe" if exist "%%~fD\npm.cmd" (
"%%~fD\node.exe" -e "v=process.versions.node.split('.');M=+v[0];m=+v[1];process.exit((M===20&&m>=19)||(M===22&&m>=12)||M>=23?0:1)" >nul 2>&1
if not errorlevel 1 set "NODE_DIR=%%~fD"
)
)
if not defined NODE_DIR (
echo [ERROR] A compatible Node.js portable runtime was not found under:
echo %REPO_ROOT%\.tools
echo Vite requires Node.js 20.19+ or 22.12+.
echo.
pause
exit /b 1
)
set "NPM_EXE=%NODE_DIR%\npm.cmd"
if not exist "%NPM_EXE%" (
echo [ERROR] npm.cmd was not found:
echo %NPM_EXE%
echo.
pause
exit /b 1
)
if not exist "%FRONTEND_DIR%\node_modules\.bin\vite.cmd" (
echo [ERROR] Frontend dependencies are not installed.
echo Run the following command first:
echo cd /d "%FRONTEND_DIR%"
echo call "%NPM_EXE%" ci
echo.
pause
exit /b 1
)
pushd "%FRONTEND_DIR%" >nul
if errorlevel 1 (
echo [ERROR] Unable to enter the frontend directory:
echo %FRONTEND_DIR%
echo.
pause
exit /b 1
)
set "PATH=%NODE_DIR%;%PATH%"
echo Starting ReactFlow at http://127.0.0.1:5173
echo Press Ctrl+C to stop the service.
echo.
call "%NPM_EXE%" run dev -- --strictPort
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
pause
)
popd
exit /b %EXIT_CODE%
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -u
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)"
FRONTEND_DIR="$REPO_ROOT/frontend"
node_version_supported() {
local version="$1"
local major
local minor
[[ "$version" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1
major=$((10#${BASH_REMATCH[1]}))
minor=$((10#${BASH_REMATCH[2]}))
(( (major == 20 && minor >= 19) || (major == 22 && minor >= 12) || major >= 23 ))
}
if [[ ! -f "$FRONTEND_DIR/package.json" ]]; then
echo "[ERROR] Frontend package.json was not found:" >&2
echo " $FRONTEND_DIR/package.json" >&2
exit 1
fi
NODE_EXE=""
NPM_EXE=""
for candidate in "$REPO_ROOT"/.tools/node-*-linux-x64/bin; do
if [[ -x "$candidate/node" && -x "$candidate/npm" ]]; then
CANDIDATE_VERSION="$("$candidate/node" --version 2>/dev/null || true)"
if node_version_supported "$CANDIDATE_VERSION"; then
NODE_EXE="$candidate/node"
NPM_EXE="$candidate/npm"
PATH="$candidate:${PATH:-}"
break
fi
fi
done
export PATH
if [[ -z "$NODE_EXE" ]]; then
NODE_EXE="$(command -v node || true)"
NPM_EXE="$(command -v npm || true)"
fi
if [[ -z "$NODE_EXE" || -z "$NPM_EXE" ]]; then
echo "[ERROR] Node.js and npm were not found." >&2
echo "Install Node.js 20.19+ or 22.12+ and make node/npm available on PATH." >&2
exit 1
fi
NODE_VERSION="$("$NODE_EXE" --version 2>/dev/null || true)"
if ! node_version_supported "$NODE_VERSION"; then
echo "[ERROR] Unsupported Node.js version: $NODE_VERSION" >&2
echo "Vite requires Node.js 20.19+ or 22.12+ (Node.js 21 is not supported)." >&2
exit 1
fi
if [[ ! -x "$FRONTEND_DIR/node_modules/.bin/vite" ]]; then
echo "[ERROR] Frontend dependencies are not installed." >&2
echo "Run the following command first:" >&2
echo " cd \"$FRONTEND_DIR\" && \"$NPM_EXE\" ci" >&2
exit 1
fi
cd "$FRONTEND_DIR"
echo "Starting ReactFlow at http://127.0.0.1:5173"
echo "Press Ctrl+C to stop the service."
echo
exec "$NPM_EXE" run dev -- --strictPort
+24 -52
View File
@@ -1,65 +1,37 @@
# 开发文档索引 # 文档目录说明
本目录是 SystemSimulationApp 协议和开发规范的统一入口。 本目录是 SystemSimulationApp 更新日志、现行标准和其他技术报告的统一入口。
跨 HTTP、组件目录、模型合同和 System XML 的版本边界统一见 ## 目录职责
[后端接口版本与定义规范 v1](backend-interface-version-spec-v1.md)。
## 更新记录 | 目录 | 职责 |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [`update-log/`](update-log/) | 按日期保存每日更新日志,记录当天已经完成的上传、修改及其影响。 |
| [`standard/`](standard/) | 保存当前采用的标准、协议和开发规范。实现、评审和 AI 修改代码时,应优先以这里的文档为准。 |
| [`other/`](other/) | 保存不属于现行标准的其他文档,例如性能仿真报告、优化报告、调研记录和技术总结。这些文档主要用于分析和参考,不默认作为强制规范。 |
- [更新日志 2026-08-15](更新日志-2026-08-15.md) 新增或移动文档时,应根据文档用途放入对应目录。目录链接可用于查看其中的全部文档,无需在本文件中逐项维护清单。
## 求解与性能 ## `update-log` 书写规范
- [后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md) 以下规范适用于新建和后续追加的日志。历史日志缺少准确完成时间时,不猜测或补写时间。
- [仿真性能评估 2026-08-15](仿真性能评估-2026-08-15.md)
## 模型开发 1. 按日期填写日志。每个文件只记录一天的更新,文件名使用 `更新日志-YYYY-MM-DD.md`,标题使用 `# 更新日志 YYYY-MM-DD`。
2. 同一天内有多次上传或更新时,按各项工作的实际完成时间分段记录。每段使用北京时间、24 小时制的 `## HH:mm` 标注时间,并按完成时间从早到晚排列。
3. 不同时间完成的内容应分别记录,不要合并到同一时间段。一次上传或更新包含多项相关修改时,可以写在同一时间段内。
4. 语言应简短且信息充分,让 AI 和人都能快速理解。优先说明完成了什么、结果是什么、影响哪些范围,删除重复描述和无关过程。
5. 尽量使用通俗易懂的语言,减少难以理解的术语。必须使用专业术语时,应提供必要的简短说明。
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md) 推荐格式:
用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。
2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。
3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json)
`GET /api/components/catalog` 的机器可读结构。
4. [AMESim 子模型公开组件迁移矩阵](amesim-component-migration-matrix.md)
用于划分 `test_mql` 子模型族的公开组件、内部模型和暂不支持范围。
5. [AMESim 氦气 Peng-Robinson 介质模型](amesim-helium-peng-robinson.md)
记录本地 AMESim 资料、氦气参数、索引映射和首版计算边界。
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有 ```markdown
模型。不要从前端兜底数据反推后端物理契约。 # 更新日志 YYYY-MM-DD
## System XML ## HH:mm
- [System XML v3 协议(当前规范)](system-xml-v3.md) - 完成的修改、结果及影响范围。
- [System XML v3 XSD(当前 Schema)](../schemas/system-simulation-v3.xsd)
新增模型时,模型类和组件库清单是后端事实来源;System XML v3 只保存求解所需的 ## HH:mm
组件实例、模型版本、参数、连接和仿真设置。端口契约由注册模型恢复,画布位置、图标
方向等编辑信息只属于工程 JSON。XML 解析器不能自行创造模型端口或参数。
## 当前代码入口 - 完成的修改、结果及影响范围。
```
| 目的 | 文件 |
| --- | --- |
| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) |
| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) |
| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) |
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
| AMESim 第一版公开临时库清单 | [`app/simulation/components/amesim/library.py`](../app/simulation/components/amesim/library.py) |
| `test_mql` 固定算例入口 | [`app/simulation/examples/test_mql/system.py`](../app/simulation/examples/test_mql/system.py) |
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
| System XML v3 解析与语义校验 | [`app/system_xml.py`](../app/system_xml.py) |
| System XML v3 XSD | [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd) |
## AI 使用原则
- 先读规范和相邻模型,再改代码。
- 只从 `library.py` 受控登记公开模型。
- 不在前端复制后端端口、参数或默认值作为正式来源。
- 不覆盖用户已有改动。
- 不自行猜测缺失的物理方程。
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
@@ -4,7 +4,7 @@
适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system` 适用模型:`AmesimModels/test_mql.ame` / `app.simulation.examples.test_mql.system`
配套规范:[`component-model-authoring-spec-v1.md`](component-model-authoring-spec-v1.md) 配套规范:[`component-model-authoring-spec-v1.md`](../standard/component-model-authoring-spec-v1.md)
## 目标 ## 目标
@@ -406,8 +406,8 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件
### 10.1 进程与线程 ### 10.1 进程与线程
- `start-all.bat` 分别启动 Vite 与 FastAPI(`start-all.bat:19-22`)。 - `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。
- `start-backend.bat` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker(`start-backend.bat:17-21`)。 - `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。
- 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。 - 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。
- 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。 - 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。
- `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。 - `POST /api/system-xml/simulate` 是 `async def`,但直接执行同步 CPU 求解;若调用该端点,会占用当前 Uvicorn 事件循环。
@@ -593,5 +593,5 @@ O(组件 + 连接 + 代数结构)
| 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` | | 可选性能埋点 | `app/simulation/performance.py` | `profile_run()`、`profile_phase()`、`profile_property()` |
| 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` | | 可重复性能基准 | `app/simulation/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` |
| 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 | | 前端流式协议 | `frontend/src/App.tsx` | `streamSystemSimulation()`、取消/轮询 |
| 启动方式 | `start-backend.bat:17-21` | Uvicorn 单 worker 命令 | | 启动方式 | `bat/start-backend.bat`、`bat/start-backend.sh` | Uvicorn 单 worker 命令 |
| 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 | | 主路径回归测试 | `tests/test_generic_system_xml_simulation.py`、`tests/test_core_solver.py` | 通用仿真、事件、取消 |
@@ -79,7 +79,7 @@
| 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 | | 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 |
| 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 | | 5 | JSON/XML | `ReactFlowPortDefinition`、System XML v3 XSD | JSON 搬运画布和端口显示快照;XML 只搬运可执行模型,端口合同由注册表恢复 |
**[约定]** `docs/README.md:28-29` 和组件建模规范都说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。 **[约定]** [组件模型建模规范 v1](../standard/component-model-authoring-spec-v1.md)说明:组件模型类及受控库清单是后端事实来源。XML 或前端不能凭空创造一个模型没有声明的端口。
## 2. 常见术语翻译表 ## 2. 常见术语翻译表
@@ -337,7 +337,7 @@ amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机
### 5.3 System XML v3:交给后端的精简求解清单 ### 5.3 System XML v3:交给后端的精简求解清单
XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/system-xml-v3.md`。 XML v3 和工程 JSON 不再追求“保存同一份完整工程”。两者分工很明确:工程 JSON 保存怎样编辑和显示,XML v3 保存后端求解什么。当前结构由 `schemas/system-simulation-v3.xsd` 定义;完整规范见 `docs/standard/system-xml-v3.md`。
#### 5.3.1 生成出来的 XML 是什么结构 #### 5.3.1 生成出来的 XML 是什么结构
@@ -554,7 +554,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
### 7.6 不从旧格式推断当前行为 ### 7.6 不从旧格式推断当前行为
当前格式只看 `docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。 当前格式只看 `docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。旧格式中的 `Port`、布局字段、端点 `role`、`Simulation/@step` 和“旋转改变力方向”都不能继续套用到 v3。
## 8. 当前模型覆盖范围 ## 8. 当前模型覆盖范围
@@ -629,7 +629,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解
| 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` | | 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` |
| JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` | | JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` |
| XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` | | XML 解析和语义检查 | `app/system_xml.py` | `SystemXmlComponent`、`SystemXmlEndpoint`、`SystemXmlDocument`、`validate_system_xml_document()` |
| XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/system-xml-v3.md` | `sampleStep`、`modelVersion`、`Parameter`、`Endpoint` | | XML v3 当前格式 | `schemas/system-simulation-v3.xsd`、`docs/standard/system-xml-v3.md` | `sampleStep`、`modelVersion`、`Parameter`、`Endpoint` |
| 目录 JSON 格式 | `schemas/component-catalog-v1.schema.json:52-160` | `$defs.portVariable`、`$defs.port` | | 目录 JSON 格式 | `schemas/component-catalog-v1.schema.json:52-160` | `$defs.portVariable`、`$defs.port` |
| 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` | | 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` |
| 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` | | 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` |
@@ -0,0 +1,601 @@
# 求解器性能与鲁棒性优化任务清单
> 用途:记录求解器优化的现状、证据、实施顺序和验收结果,供后续开发前后对比与持续更新。
> 首次建立:2026-08-17
> 基线代码:`6bb0591d320d0c448ee8d224dd44127bfe3ce00f`(本地 `model-development`)
> 基线模型:`tests/data/test_mql-full-branches-01-04.xml`
> 模型 SHA-256:`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`
## 1. 使用规则
本文档不是一次性的建议列表,而是优化工作的验收账本。
- 状态统一使用:`未开始`、`进行中`、`部分实现`、`已完成`、`阻塞`、`不采用`。
- 只有同时完成代码、自动测试、基准复测和本文档更新后,任务才可标记为“已完成”。
- 每次性能对比必须记录代码提交、工作树状态、输入哈希、解释器与依赖版本、硬件和运行参数。
- 正确性门槛先于速度收益。若结果越过误差契约,即使运行更快也不能合入默认路径。
- 容差、模型方程或输出字段发生变化时,必须单独说明;不得将其伪装成纯性能优化。
- 墙钟时间只在同一台机器、相同负载和相同环境下直接比较;跨环境以工作量计数和正确性指标为主。
- 每项优化都应保留明确的关闭开关或旧路径,直到新路径经过复杂模型和通用回归验证。
## 2. 当前结论与基线
### 2.1 关于 2.05 s 卡死
当前随附 XML 的磁盘配置是 `tStop=0.81 s`,因此原文件本身不会运行到 2.05 s。将停止时间仅在内存中改为 `2.10 s` 后,当前代码已经完整越过 2.05 s 并正常结束:
- `2.040432 s`:墙钟 `114.065 s`
- `2.046141 s`:墙钟 `120.746 s`,期间 CPU 时间持续增长
- `2.051691 s`:墙钟 `121.548 s`
- `2.100000 s`:完成积分并进入后处理
- 总运行完成,无重试、无非线性回退,也没有无进度死锁
因此,当前证据支持“此前的 2.05 s 卡死在现版本中没有复现”;该区间仍存在数秒级慢推进。`10 s` 尚未验证,不能由本次结果外推保证。
### 2.2 环境说明
仓库内 `.venv` 当前不完整,本次复测使用现有 `/opt/srm-trial-review/.venv`:
| 项目 | 本次值 |
| --- | --- |
| Python | 3.12.3 |
| NumPy | 2.4.6 |
| SciPy | 1.17.1 |
| 求解器 | BDF |
| 输出步长 | 0.01 s |
| 执行路径 | stream/cancel-check |
该环境满足仓库依赖范围,但并非已经锁定的正式项目环境。当前物理解哈希与历史调研文档不同,所以逐位结果基线必须在正式锁定环境中再次确认。
### 2.3 当前实测基线
| 指标 | 原始 `0.81 s` | 仅内存延长至 `2.10 s` |
| --- | ---: | ---: |
| 状态 | 完成 | 完成,越过 2.05 s |
| 墙钟时间 | 63.779 s | 126.211 s |
| 积分时间 | 62.116 s | 122.180 s |
| 后处理时间 | 1.118 s | 2.703 s |
| 最大 RSS | 165,464 KiB | 198,348 KiB |
| 输出样本数 | 82 | 213 |
| 状态数 | 74 | 74 |
| `nfev / njev / nlu` | 3763 / 253 / 761 | 6734 / 487 / 1507 |
| 接受步 | 1076 | 1857 |
| 分段启动 | 3 | 5 |
| 状态切换 | 0 | 2 |
| 重试 | 0 | 0 |
| 有限差分附加 RHS 估计 | 7843 | 15097 |
| 压力闭合次数 | 30,502 | 57,601 |
| 非线性/块/稠密回退 | 0 / 0 / 0 | 0 / 0 / 0 |
| 最大热流体外迭代 | 3 | 3 |
| Jacobian 稀疏度 | 1284 nnz / 31 色 | 1284 nnz / 31 色 |
补充观察:
- 积分占总耗时约 97%,当前首要瓶颈不是后处理。
- `2.10 s` 运行中,估计总 RHS 工作量约为 `6734 + 15097 = 21831`;有限差分扰动约占 69%。
- 压力闭合约为每次估计 RHS 2.64 次,但全部走已播种的因果路径,没有触发 `least_squares`。
- 全局因果执行已启用:快速执行 22,216 次,完整残差审计 351 次,审计失败 0 次,旧路径回退 0 次,审计间隔为 64。
- 当前结果哈希仅作为本次环境的诊断记录:`0.81 s` 为 `c6354c97...`,`2.10 s` 为 `efef49f8...`;它们暂不作为跨环境验收标准。
### 2.4 当前模型结构基线
| 项目 | 数量 |
| --- | ---: |
| XML 组件 / 编译组件 | 99 / 98 |
| 连接 | 106 |
| 连续状态 | 74 |
| 代数未知量 / 方程 | 472 / 472 |
| 原始关联矩阵非零元 / 方程块 | 919 / 58 |
| effort 未知量 / flow 未知量 | 272 / 200 |
| effort 等价组 / 可消去重复 effort | 68 / 204 |
| 显式 flow/force 赋值 | 200 |
| stream 块 / stream 未知量 | 9 / 192 |
| 结果变量 | 1,021 |
## 3. 总体验收协议
每个优化 PR 至少执行以下分层验证;高风险改动不得只用单点输出或单个哈希判断正确性。
### 3.1 快速结构检查(CI)
- [ ] 模型输入 SHA-256 与固定 fixture 一致。
- [ ] 组件、连接、状态、代数方程和 stream 结构数量符合预期。
- [ ] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。
- [ ] 因果计划覆盖率、回退原因和审计失败数可观测。
### 3.2 数值检查点
至少覆盖以下区间和模式边界:
- [ ] `0.68–0.71 s`:历史慢区。
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
- [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。
- [ ] `10 s`:长时间模式变化验证,完成 OPT-09 后启用。
每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。
### 3.3 性能记录
每次正式对比至少预热 1 次、测量 3 次并报告中位数,同时保存:
- 总时间、积分时间、后处理时间、CPU 利用率、峰值 RSS。
- `nfev`、`njev`、`nlu`、接受/拒绝步、分段和重试次数。
- SciPy 模式的有限差分 RHS 估计;callable 模式的真实扰动、基准和 Jv 审计 RHS 计数;Jacobian 颜色数与构建时间。
- 代数闭合次数、快速因果次数、完整审计次数和各类回退次数。
- stream/热流体迭代次数、物性缓存命中率、事件候选与定位次数。
- 输出标量数、编码字节数、传输字节数和后处理峰值内存。
## 4. 优化任务总览
优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
| --- | --- | --- | --- | --- | --- | --- |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 进行中 | 中 | 很高 | 无 |
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 部分实现 | 中高 | 中高 | OPT-00 |
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 未开始 | 很高 | 很高 | OPT-01 |
| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 |
| OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 |
| OPT-05 | P1 | 状态缩放、分量容差和步长策略 | 未开始 | 中高 | 中高 | OPT-00 |
| OPT-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 |
| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 |
| OPT-08 | P2 | 进度、取消和服务并发鲁棒性 | 部分实现 | 中 | 中 | OPT-00 |
| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 未开始 | 中高 | 很高 | OPT-00 |
| OPT-10 | P3 | 明确高指数 DAE/强非光滑系统边界 | 未开始 | 很高 | 条件性 | OPT-09 |
推荐实施顺序:`OPT-00 → OPT-03/OPT-01 → OPT-04/OPT-05 → OPT-02 → OPT-06/OPT-07/OPT-08 → OPT-09`。其中 OPT-02 与 OPT-03 可先做最小原型,再根据端到端数据调整顺序。
## 5. 详细任务
### OPT-00 固化复现、环境和回归基线
**目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。
**当前状态**:已有手工 `0.81 s` 和 `2.10 s` 复测及若干结构回归;复杂 XML、正式运行环境、分层性能门槛尚未完整固化。
**工作项**:
- [ ] 将复杂 XML 作为正式测试 fixture 纳入版本控制,并在测试中校验哈希。
- [ ] 修复或重建项目 `.venv`,锁定 Python、NumPy、SciPy 及平台信息。
- [ ] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。
- [ ] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的标准运行入口。
- [ ] 添加模型结构快照断言;结构有意变化时显式更新原因。
- [ ] 定义 `physical-state-v2`:仅包含物理状态、关键代数量、事件与模式,不包含展示字段和易变诊断字段。
- [ ] 将完整 API 输出哈希与物理解哈希分开,分别用于输出契约和数值回归。
- [ ] 建立短 CI、夜间 `0.81/2.10 s`、定期 `10 s` 三层任务。
- [ ] 保存机器可读的 JSON 基准结果,避免只在文档中抄写数字。
**验收条件**:
- [ ] 干净环境一条命令可复现;失败时能区分超时、无进度、数值失败和服务失败。
- [ ] 正式环境连续 3 次完成 `2.10 s`,结果满足数值契约且无非预期回退。
- [ ] 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。
**前后对比**:
| 指标 | 当前 | 完成后 |
| --- | --- | --- |
| 正式锁定环境 | 无 | 待填 |
| 复杂模型自动回归 | 部分 | 待填 |
| 物理解哈希 | 环境相关 v1 | 待填 |
| `2.10 s` 连续成功率 | 单次证据 | 待填 |
### OPT-01 完成因果代数内核与坐标消元
**目标**:在已存在的因果快速路径上,真正移除运行时冗余坐标和对象访问,而不是再次实现一套同类快速路径。
**当前状态**:主要思路已经实现。全局和 secondary stream 块可以执行显式因果计划,完整残差按 64 次间隔审计;`2.10 s` 中快速执行 22,216 次、审计 351 次、失败和旧路径回退均为 0。仍保留 472 个运行时未知量,204 个重复 effort 坐标尚未在执行层消除,且存在清零、复制、缩放、`getattr/setattr` 和完整对象遍历成本。
一次已预热的 A/B 微基准显示,整个 RHS 的因果快速模式中位数约 `200.8 ms/100 次`,强制完整检查约 `297.4 ms/100 次`,即现有路径已经取得约 `1.48×` 的整 RHS 收益。单纯继续增大审计间隔预计收益有限。
**工作项**:
- [ ] 将 68 个 effort 等价组压缩为独立运行时坐标,消除 204 个重复 effort 槽。
- [ ] 将 200 条显式 flow/force 规则预编译为稳定顺序和整数槽索引。
- [ ] 用预分配连续数组代替热路径对象读写、临时字典和重复缩放。
- [ ] 仅清理会被当前计划写入的槽,避免每次全量清零和复制。
- [ ] 保留初始化、事件后、接受步或固定间隔的完整残差审计。
- [ ] 自定义组件、声明缺失、审计失败或奇异结构必须自动回退旧求解器。
- [ ] 输出编译统计:消元数、显式规则覆盖率、审计率、失败原因和回退次数。
**验收条件**:
- [ ] 复杂模型因果覆盖率大于 98%,完整 `0.81/2.10 s` 运行审计失败为 0。
- [ ] 新旧路径的状态、事件、关键代数量和残差均满足统一数值契约。
- [ ] 自定义组件、接触模型和非因果结构的回退测试全部通过。
- [ ] 在完整模型上证明端到端收益;不得只提交代数微基准。
**风险与回滚**:别名写回、事件后模式改变和不完整依赖声明可能造成静默错误。新路径必须可通过配置关闭,并在审计失败时记录首个违规方程与变量。
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| 运行时代数槽 | 472 | 待填 |
| 重复 effort 槽 | 204 | 待填 |
| 已预热 Python 调用/单 RHS | 约 15,774 | 待填 |
| 因果审计失败 | 0 | 待填 |
| `0.81/2.10 s` 墙钟中位数 | 63.779 / 126.211 s(单次环境值) | 待填 |
### OPT-02 建立扁平数值 IR 和数组执行内核
**目标**:把组件对象、字典查找和端口读写转换成稳定的数值执行计划,为 NumPy、Numba 或原生后端提供共同基础。
**当前状态**:构建阶段已有一定预绑定,但 RHS 仍以 Python 对象和方法调用为主。已预热、启用物性缓存时,采样剖析约有 15,774 次 Python 调用/RHS;不同缓存上下文会明显改变该数字,所以后续必须统一测量口径。
**工作项**:
- [ ] 定义最小数值 IR:连续槽、常量、参数、状态、代数量、模式位和操作码。
- [ ] 将组件方程、因果规则、stream 传播和结果提取分成明确执行阶段。
- [ ] 先实现可逐项对照的纯 Python/NumPy 参考后端。
- [ ] 添加 IR 与当前对象执行器逐操作/逐阶段差分测试。
- [ ] 评估 Numba 与 C/C++ 后端;在 IR 稳定前不绑定单一编译技术。
- [ ] 对动态自定义组件保留对象适配层和明确的性能降级提示。
- [ ] 缓存编译结果,并以模型结构、组件版本和数值后端作为缓存键。
**验收条件**:
- [ ] 全部现有组件族通过新旧执行器差分测试。
- [ ] 事件切换后能正确重编译或选择预编译模式计划。
- [ ] 明显降低 Python 调用数、对象分配和 RHS 中位时间,并改善完整仿真墙钟。
- [ ] 不以牺牲异常信息、取消检查或回退能力换取速度。
| 指标 | 当前 | 原型后 | 完成后 |
| --- | ---: | ---: | ---: |
| Python 调用/单 RHS | 约 15,774 | 待填 | 待填 |
| 临时分配字节/单 RHS | 待测 | 待填 | 待填 |
| RHS 中位时间 | 约 2 ms(现有微基准口径) | 待填 | 待填 |
| `2.10 s` 积分时间 | 122.180 s | 待填 | 待填 |
### OPT-03 稀疏 Jacobian 数值层与解析/半解析演进
**目标**:先建立可审计、可回滚的 callable sparse Jacobian 数值层,再逐步把组件、因果代数计划、stream 和物性的局部导数传播进来。完整稀疏有限差分、受审计 secant 和真正的解析/半解析 Jacobian 是三个不同阶段,必须分别记录和验收。
**当前状态**:数值层基础与实验候选已经实现;首批“证明门控”的三活塞 6 列半解析切片已经接入,但通用组件、stream SCC 和其余状态列仍未覆盖,因此 OPT-03 总体继续标记为“部分实现”。默认执行路径继续使用 SciPy `jac_sparsity`,半解析路径只允许通过 `SIMULATION_ODE_JACOBIAN_MODE=semi-analytic` 显式启用。
现有实现包括:
- direct 和 stepwise BDF/Radau 均可接收 callable `jac`;信号断点、状态事件和可恢复重启会清空 Jacobian 数值状态并重新构建,显式积分器完全忽略该对象。
- 新增独立的 sparse numerical Jacobian 内核,隔离并检查 SciPy 私有 `num_jac/group_columns` 接口。
- 每个 solver segment 记录完整构建、有限差分扰动、基准 RHS、Jv 审计、secant 复用/失败和装配时间;SciPy 模式的估计值不再伪装成 callable 模式的真实计数。
- 4 条无离散端挡模式歧义的机械运动学行直接装配为 `d(x')/d(v)=1`;带端挡的行继续数值差分。
- callable 内核新增 `exact_columns=(indices, provider)`:已提供精确导数的列从分组有限差分中移除,其余列仍按原始保守结构做 subset FD;原始色数、剩余色数、单次真实 FD、精确列构建及回退次数/原因都进入分段诊断。
- 精确列提供器用类型化 `ExactColumnsUnavailable` 表达当前点不可用;同一次构建会恢复原始 seed 0 的完整数值 Jacobian,避免把未知导数静默当成 0。模型编译证明失败、SciPy 私有接口不兼容或配置关闭时则直接保留原生 SciPy 路径。
- 首批目标是三条同构活塞支路的 6 个机械状态列 `(20, 21, 38, 39, 54, 55)`。编译器只有在组件类型、连接拓扑、因果赋值计划、机械等价组和 stream 影响范围都满足证明条件时才启用;该 XML 中共覆盖 34 条 reachable assignments,FD 颜色由 31 降至 25,另由提供器装配 6 列。
- 已增加 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的局部切向原语;每个原语都返回 `valid/reason`,以便在非光滑接触、临界流动或不支持的模式上拒绝解析近似。
- Jacobian 内部每次 RHS 都执行取消检查;不安全的共享模型基准缓存已经撤销。随后实现的一次性 generation/dirty token 安全版本在正式 `0.81 s` 中 `253` 次 Jacobian 请求命中 `0` 次:BDF 首次构建前会做初始步长试算,后续构建前也会留下 Newton 试探状态,模型并不位于请求的基准点。该版本没有节省 RHS,最终也已删除。
- `SIMULATION_ODE_JACOBIAN_MODE=scipy` 是默认和回滚路径;小型全稠密结构或 SciPy 私有接口不兼容时也回到该路径。
显式 `SIMULATION_ODE_JACOBIAN_MODE=optimized` 仍构造完整稀疏有限差分 Jacobian,不使用 secant。最终实现严格固定 SciPy seed 0,并从原始 `1284 nnz` 保守结构生成 31 色扰动批次;移除精确行不会重新着色。曾试验的 seed 54 为 30 色,结构虽未删边,却改变了事件敏感模型的运行轨迹,因此多 seed 自动择优已经从代码中删除。
历史 30 色候选有性能收益,但没有通过事件/状态等价验收:
- 最终安全版本的 `0.81 s` 单次相邻 A/B 中,optimized 积分 `55.034 s`、总墙钟 `56.957 s`,SciPy 基线积分 `59.924 s`、总墙钟 `61.953 s`,分别约改善 `8.2% / 8.1%`。
- `0.81 s` 中 callable 实际 Jacobian RHS(扰动加基准)为 `7,103`,SciPy 估计为 `8,096`,约减少 `12.3%`;`nfev/njev/nlu` 为 `3467/228/670`,基线为 `3763/253/761`。
- 两条 `0.81 s` 轨迹具有相同结果键、采样时刻、0 次状态切换和约 `1e-16` 的最大代数残差,但最终 74 维状态的最大差异为 `51.59 × (atol + rtol·|y|)`,最差状态相对差约 `5.2e-5`,超过当前拟定的严格等价门槛。
- `2.10 s` optimized 仍成功越过 2.05 s,积分 `115.928 s`,而 SciPy 基线为 `122.180 s`;但 optimized 出现 `4` 次状态切换、`7` 次 solver 启动和 `215` 个样本,基线为 `2 / 5 / 213`。因此该候选的事件等价验收失败,不能设为默认。
- 短变体隔离显示:seed 0 callable(有或没有 4 条精确行)在 `0.01 s` 的最终 74 维状态与 SciPy 逐项一致;轨迹分叉来自 30 色 seed 54,而不是精确运动学行。这提示事件敏感模型需要更多运行中 Jacobian 漏边/弱依赖审计,不能只依赖初始点结构测试。
最终 seed 0 安全候选的正式 `0.81 s` 探针与 SciPy 基线具有相同的物理解哈希 `c6354c97...`、`3763/253/761` 的 `nfev/njev/nlu`、`1076` 个接受步、3 次 solver 启动、0 次状态切换和 `30,502` 次压力闭合。实际 Jacobian 内部 RHS 为 `7,872 + 253 = 8,125`;安全 token 缓存命中为 0。积分时间 `60.972 s`、探针总墙钟 `62.945 s`,相邻 SciPy 基线为 `59.924/61.953 s`,没有净收益并略有退化。因此安全缓存已删除,seed 0 callable 只保留为后续解析行接入与诊断基础,不进入默认路径;无需为一个已经失败收益门槛的候选继续做 `2.10 s` 性能复测。
`SIMULATION_ODE_JACOBIAN_MODE=hybrid` 另提供实验性的数值 secant 原型:最多连续复用一次,复用前执行确定性方向 Jv 审计,失败或审计无信息量会在同一次调用中完整刷新。目标模型的早期探针中候选审计普遍失败;用 seed 0 的旧完整 Jacobian 做 `0.01 s` 探针时,39 次复用审计全部失败,额外产生 39 次 Jv RHS,实际复用仍为 0。因此它目前既不是解析 Jacobian,也没有可声明的端到端收益。
**已完成的数值层工作**:
- [x] direct/stepwise BDF、Radau callable `jac` 接线;显式方法隔离。
- [x] breakpoint、事件、可恢复重启后的强制重建与分段计数。
- [x] 完整稀疏有限差分内核、严格 seed 0 着色、4 条安全精确行。
- [x] exact-columns subset FD、类型化同次完整回退和原始/剩余色数及回退诊断。
- [x] 真实 RHS/装配计数,以及 SciPy 估计口径分离。
- [x] Jacobian 内部有界取消检查;撤销不安全缓存及命中为 0 的安全 token 缓存。
- [x] 稠密结构、兼容问题和配置关闭时保留 SciPy 路径。
- [x] 最多一次复用、Jv 审计、无信息审计拒绝和失败完整刷新测试。
- [x] 复杂 XML `0.81/2.10 s` 单次性能与事件探针。
- [x] 同一代码版本完成 3 组相邻 `0.81 s` A/B,报告中位数与范围。
- [ ] 为事件敏感模型定义并通过状态、事件时刻/顺序和模式等价契约。
- [ ] 在正式锁定环境完成独立预热后的 3 次 A/B,复核中位数与离散度。
**解析/半解析后续工作**:
- [x] 为首批 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 路径定义带有效性诊断的局部切向契约。
- [x] 对目标三活塞 6 列沿 34 条可证明因果赋值传播导数,并从 FD 分组中排除这些列。
- [ ] 将局部导数/JVP 契约扩展到其余基础与自定义组件。
- [ ] 将因果传播推广到目标切片以外的状态列和代数计划。
- [ ] 对 stream SCC 推导显式或隐式小块导数。
- [ ] 对物性函数提供解析导数、可靠自动微分或受控局部差分接口。
- [ ] 在接触、饱和、开关和临界模式附近使用分段导数与局部回退。
- [ ] 对自定义组件缺失的导数声明生成明确诊断,不得静默置零。
- [ ] 在 `0.68–0.71`、`0.79–0.81`、事件两侧和 `2.00–2.10 s` 检查点执行稠密数值漏边审计与随机方向 JVP。
**验收条件**:
- [x] 历史 external-volume 跨域结构护栏与初始点稠密数值漏边测试继续通过。
- [x] callable 接线、分段重置、取消、显式方法隔离、secant 上限和审计失败回退有自动测试。
- [ ] `0.81/2.10 s` 的连续状态、事件时刻/顺序、模式和残差满足统一契约;当前 30 色候选未通过。
- [ ] 默认候选在锁定环境的 3 次中位墙钟有净收益,小模型无显著退化。
- [x] 首批目标切向原语和 6 列通过逐列中心差分、模式分支与局部回退验证。
- [ ] 通用组件级解析/半解析导数通过随机方向 JVP、逐列抽查和局部回退验证。
**风险与回滚**:历史 external-volume 漏边说明“颜色更少”本身不是正确性证据。不同合法颜色组合也可能暴露保守结构中未声明的弱依赖,并改变非光滑接触附近的事件序列。默认保持 `scipy`;`optimized/hybrid` 仅显式实验。非光滑点的解析或 secant 近似未必可靠,事件分段重置、审计和旧路径必须长期保留。
| 历史实验指标 | SciPy 基线 | 已撤销的 30 色候选 | 验收 |
| --- | ---: | ---: | --- |
| 保守结构 / 实际 FD 颜色 | 1284 nnz / 31 | 1284 nnz / 30(seed 54) | 结构不删边 |
| 精确装配行 | 0 | 4 条运动学行 | 短变体证明不改变轨迹 |
| `0.81 s` Jacobian RHS(含基准) | 估计 8,096 | 实际 7,103 | `-12.3%` |
| `0.81 s` `nfev/njev/nlu` | 3763 / 253 / 761 | 3467 / 228 / 670 | 工作量下降 |
| `0.81 s` 积分 / 总墙钟 | 59.924 / 61.953 s | 55.034 / 56.957 s | 单次约 `-8.2% / -8.1%` |
| `0.81 s` 最大最终状态误差尺度 | 参考 | 51.59 | 未通过 |
| `2.10 s` Jacobian RHS(含基准) | 估计 15,584 | 实际 14,556 | `-6.6%` |
| `2.10 s` `nfev/njev/nlu` | 6734 / 487 / 1507 | 6606 / 468 / 1469 | 工作量小幅下降 |
| `2.10 s` 积分时间 | 122.180 s | 115.928 s | 单次约 `-5.1%` |
| `2.10 s` 状态切换 / solver 启动 / 样本 | 2 / 5 / 213 | 4 / 7 / 215 | 未通过 |
| 最终安全候选指标(`0.81 s`) | SciPy 基线 | seed 0 callable | 验收 |
| --- | ---: | ---: | --- |
| 保守结构 / FD 颜色 | 1284 nnz / 31 | 1284 nnz / 31(seed 0) | 相同扰动批次 |
| `nfev/njev/nlu` | 3763 / 253 / 761 | 3763 / 253 / 761 | 相同 |
| 接受步 / solver 启动 / 状态切换 | 1076 / 3 / 0 | 1076 / 3 / 0 | 相同 |
| 压力闭合 | 30,502 | 30,502 | 相同 |
| 物理解哈希 | `c6354c97...` | `c6354c97...` | 通过 |
| 安全基准 RHS 缓存命中 | 不适用 | 0 / 253 | 无收益,代码已删除 |
| 积分 / 探针总墙钟 | 59.924 / 61.953 s | 60.972 / 62.945 s | 略有退化,未通过收益门槛 |
#### 2026-08-17 / 工作树基于 `6bb0591d`
- 状态:未开始 → 部分实现(数值接入层完成;30 色候选未通过事件等价,seed 0 候选未通过收益门槛;解析/半解析传播未开始)
- 代码备份:`backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。该分支只备份 tracked 代码基线,不包含当时未跟踪的本文档。
- 运行环境:Python 3.12.3、NumPy 2.4.6、SciPy 1.17.1;输入 SHA-256 `2fb95e65...`;`2.10 s` 仅内存覆盖停止时间,磁盘 XML 未修改。
- 正确性结果:Jacobian 内核、core solver、Generic sparsity 和 Generic XML 共 57 项通过;压力因果、stream 块、机械接触、PNRP17、代数稀疏与方程块另 52 项通过,external-volume 漏边护栏继续通过。热流体闭合计划 13 项中 12 项通过,剩余 1 项因用户已将 fixture 移至 `tests/data/fixtures/`、旧测试仍读取 `tests/fixtures/` 而报既有 `FileNotFoundError`,与本次改动无关。30 色候选在 `2.10 s` 的事件数由 2 变为 4;最终 seed 0 候选在 `0.81 s` 恢复相同物理解哈希与求解统计。
- 性能结果:见上表。数字均为同机相邻单次结果,不是 3 次中位数;最终 seed 0 候选没有减少求解工作并略慢。
- 卡死结果:历史 30 色探针在 `2.040187 s @ 106.499 s`、`2.051323 s @ 113.996 s`、`2.065299 s @ 115.472 s` 持续推进并完成到 2.10 s;默认 SciPy 的正式探针同样越过 2.05 s 并完成,无重试、无死锁。
- 安全收口:默认保持 SciPy;移除多 seed 自动择优、不安全共享缓存和零命中的安全 token 缓存;Jacobian 内部保留取消检查;无信息 Jv 审计强制刷新;显式 solver 不观察或重置 Jacobian。
- 决策:保留严格 seed 0 的 callable/诊断/精确行基础和显式实验开关;30 色、基准缓存与 secant 均不进入默认路径。下一阶段优先建立多检查点弱依赖审计和组件级局部导数,不再以颜色数或数值缓存单独作为优化成功标准。
- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_core_solver.py`、`tests/test_generic_jacobian_sparsity.py`、`tests/test_generic_system_xml_simulation.py`
#### 2026-08-17 / 首批三活塞半解析 6 列切片
- 状态:部分实现 → 部分实现(首批目标切片完成并通过局部导数验证;OPT-03 的通用解析/半解析覆盖尚未完成)。
- 实现范围:新增 exact-columns subset FD 接口、类型化同次完整数值回退和分段诊断;为三条目标活塞支路编译状态列 `(20, 21, 38, 39, 54, 55)`,沿 34 条可达因果赋值传播切向量,使剩余 FD 颜色从 31 降到 25。
- 局部导数:实现 Ideal/PR 介质 `m/U/V` 物性线性化,以及 PNRP、PNCH012、PNL0001、LSTP、MECMAS 的几何、压力、质量/能量、流量/力和接触模式切向原语;原语显式报告 `valid/reason`。
- 证明与回退:组件类型、连接拓扑、因果计划、机械组和静态 stream 影响范围必须全部满足编译证明。causal/stream/custom/兼容性证明不成立时不安装 callable,继续使用原生 SciPy;运行点进入非光滑接触边界、临界流动、陈旧 primal 或其他不支持模式时抛出类型化 `ExactColumnsUnavailable`,同一次构建恢复原始 seed 0 完整数值 Jacobian。任何不可证明项都不会静默填 0。
- 配置边界:默认仍为 `SIMULATION_ODE_JACOBIAN_MODE=scipy`;首批路径仅通过 `semi-analytic` 显式 opt-in,不替换生产默认值。
- 自动测试:focused 套件 86 项、adjacent 套件 164 项,共 250 项通过。热流体 closure 计划另为 12/13 项通过;唯一失败仍是旧测试读取 `tests/fixtures/`、而 fixture 已被用户移至 `tests/data/fixtures/` 导致的既有 `FileNotFoundError`,与本轮 Jacobian 改动无关。
- 局部正确性:在平滑检查点,SciPy 分组有限差分漏掉 `J[19,20] ≈ -3201.486`;半解析列相对独立中心差分的最大相对误差为 `1.897e-8`。inactive/active 接触分支、过期 primal、非因果计划和不支持拓扑均覆盖了成功或回退路径。
- 轨迹正确性:默认容差下,两条 `0.81 s` 轨迹最差点为 `t=0.65 s` 的能量状态 `state[33]`,原始相对差 `8.24e-5`,缩放误差 `82.36`;事件数和顺序一致,但尚未满足拟定的严格逐点轨迹门槛。提高精度后互差收敛:`rtol=1e-7` 时最大绝对/相对差为 `0.081965 / 1.592e-6`,`rtol=1e-8` 时为 `0.0175357 / 3.09062e-7`,分别缩小约 `4.67× / 5.15×`,且两组事件均一致。这支持“求解路径差异随容差收敛”,但不足以把候选升为默认。
- 性能口径:`0.81 s` 已在同机、同一工作树连续完成 3 组相邻 A/B;表中时间为中位数,括号给出 3 次范围。测试使用现有 `/opt/srm-trial-review/.venv`,没有独立预热且依赖版本未由项目锁文件固定,因此仍需在正式锁定环境复核,不能单独作为切换默认值的依据。`2.10 s` 为最终 one-shot primal 捕获版本的单次复跑;此前数学路径相同的预备运行墙钟为 `111.068 s`,本次为 `116.512 s`,长程时间仍需重复测量。
| 最终 `0.81 s` 三次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 |
| --- | ---: | ---: | --- |
| FD 颜色 / 精确状态列 | 31 / 0 | 25 / 6 | 目标列为 20、21、38、39、54、55 |
| `nfev/njev/nlu` | 3763 / 253 / 761 | 3650 / 228 / 711 | 求解工作下降 |
| 接受步 / solver 启动 / 状态事件 / 样本 | 1076 / 3 / 0 / 82 | 1056 / 3 / 0 / 82 | 事件和输出网格一致 |
| Jacobian RHS | 8,096(估计) | 5,985(实计) | `-26.1%` |
| 精确列构建 / 类型化回退 | 不适用 | 224 / 4 | 4 次恢复完整数值构建 |
| 压力闭合 | 30,502 | 25,672 | `-15.8%` |
| 积分时间中位数(范围) | 59.725 s(59.568–60.188) | 55.631 s(55.432–56.307) | 中位数 `-6.85%` |
| 总墙钟中位数(范围) | 61.203 s(61.070–61.704) | 56.708 s(56.508–57.410) | 中位数 `-7.34%`;逐组改善 6.96%–7.47% |
| 延长至 `2.10 s` 单次指标 | SciPy 基线 | `semi-analytic` 候选 | 变化/说明 |
| --- | ---: | ---: | --- |
| 状态 | 完成,越过 2.05 s | 完成,越过 2.05 s | 最终版本越过 2.05 s 的墙钟为 111.660 s |
| `nfev/njev/nlu` | 6734 / 487 / 1507 | 6246 / 445 / 1328 | 求解工作下降 |
| 接受步 | 1857 | 1753 | `-104` |
| solver 启动 / 状态切换 / 样本 | 5 / 2 / 213 | 5 / 2 / 213 | 事件计数和输出网格一致 |
| Jacobian RHS | 15,584(估计) | 11,771(实计) | `-24.5%` |
| 类型化回退 | 不适用 | 26 | 非平滑/不支持点恢复完整数值构建 |
| 压力闭合 | 57,601 | 48,248 | `-16.2%` |
| 积分时间 | 122.180 s | 112.825 s | 单次 `-7.7%` |
| 总墙钟 | 126.211 s | 116.512 s | 单次 `-7.7%` |
- 卡死复核:最终 `semi-analytic` 候选在墙钟 `111.660 s` 越过模拟时刻 `2.05 s`,随后于 `116.512 s` 完成到 `2.10 s`;与 SciPy 基线一样未出现无进度死锁。
- 未覆盖范围:通用 stream SCC 导数、目标三支路以外的组件/状态列、自定义组件导数契约、正式锁定环境的独立预热复测,以及 `10 s` 长时模式覆盖。
- 决策:保留首批半解析切片和自动回退作为显式实验路径;OPT-03 继续为“部分实现”,默认继续使用 SciPy。完成上述通用覆盖、严格轨迹契约和重复基准前,不切换默认值。
- 代码备份:仍使用进入 Jacobian 优化前建立的 `backup/jacobian-before-20260817-6bb0591`,精确指向 `6bb0591d320d0c448ee8d224dd44127bfe3ce00f`。
- 证据文件:`app/simulation/solvers/jacobian.py`、`app/simulation/solvers/tangent.py`、`app/simulation/solvers/solver.py`、`app/simulation/systems/generic.py`、`app/simulation/core/medium.py`、`app/simulation/components/amesim/media/mediums.py`、`app/simulation/components/amesim/mechanical/pistons.py`、`app/simulation/components/amesim/storage/chambers.py`、`app/simulation/components/amesim/flow/pipes.py`、`app/simulation/components/amesim/mechanical/translational.py`、`tests/test_sparse_secant_jacobian.py`、`tests/test_analytic_tangent_primitives.py`、`tests/test_three_piston_tangent.py`
### OPT-04 stream 拓扑传播与物性成组复用
**目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。
**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点最多 25 次,本模型实测最多 3 次。
**工作项**:
- [ ] 构建 stream 图的 SCC,并将缩点图编译为拓扑顺序。
- [ ] 对单节点和无环段使用一次传播,仅在循环 SCC 内迭代。
- [ ] 使用预分配数组和原地误差统计,避免每轮临时字典/列表。
- [ ] 缓存同一求解阶段的连接焓结果,避免返回前重复计算。
- [ ] 将 `p/T/rho/h/s` 等同源物性组织为状态包,按精确输入键成组复用。
- [ ] 增加缓存命中、SCC 迭代、失效原因和物性调用次数指标。
- [ ] 评估脏标记传播,但必须证明事件和反向流切换时不会复用陈旧值。
**验收条件**:
- [ ] 无环、单环、多环、反向流和事件后拓扑测试全部通过。
- [ ] 复杂模型的最大 stream/热流体迭代不增加,残差不恶化。
- [ ] 量化减少物性调用、临时分配、压力闭合或 RHS 时间。
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| stream 块 / 未知量 | 9 / 192 | 待填 |
| 最大热流体迭代 | 3 | 待填 |
| `2.10 s` 压力闭合 | 57,601 | 待填 |
| 物性调用 / 缓存命中率 | 待测 | 待填 |
### OPT-05 状态缩放、分量容差和步长策略
**目标**:减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。
**当前状态**:模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。
**工作项**:
- [ ] 按状态物理量、标称值和工程容差建立分量 `atol`/缩放规则。
- [ ] 为未提供标称值的组件定义安全默认值并输出诊断。
- [ ] 分开积分误差、代数残差、stream 固定点和事件定位容差。
- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
**验收条件**:
- [ ] 每个配置都有状态、事件、残差和守恒误差界限。
- [ ] 标准配置在复杂模型上减少拒步或分解工作,不引入模式遗漏。
- [ ] 所有收益报告同时给出误差变化,禁止只报告墙钟。
### OPT-06 事件检测与 dense output 按需化
**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。
**当前状态**:已有事件候选筛选和部分非事件优化,但只要存在状态转换处理器,接受步仍可能构造 dense output。`2.10 s` 有 1857 个接受步而只有 2 次状态切换,存在减少插值构造的空间。
**工作项**:
- [ ] 在构造 dense output 前执行低成本端点符号/模式候选检查。
- [ ] 仅在跨输出采样点或存在事件候选时创建插值器。
- [ ] 将输出插值与事件定位的生命周期和精度需求分离。
- [ ] 统计候选数、误报数、定位次数、dense output 构造数和耗时。
**验收条件**:
- [ ] 同时事件、擦边事件、抖动防护和多模式顺序测试通过。
- [ ] 事件时刻误差不超契约,事件顺序和最终模式不变。
- [ ] 完整模型 dense output 构造数与耗时明显下降。
### OPT-07 输出、后处理和传输内存优化
**目标**:在长仿真中控制结果生成、JSON 编码、前端复制和峰值内存。
**当前状态**:当前模型有 1,021 个结果变量;`10 s / 0.01 s` 约产生 1,022,021 个标量。现路径会对每个样本重新闭合、追加全部结果,并把完整结果作为一个 NDJSON 消息发送。它不是本次 2.05 s 慢推进的主因,但会成为长时间运行的显著成本。
**工作项**:
- [ ] 支持结果变量白名单、分组和按需派生量。
- [ ] 将积分内部采样、结果存储采样和显示采样分离。
- [ ] 对显示路径提供服务端降采样,同时保留可选完整数据模式。
- [ ] 分块编码和传输结果,或返回 `resultId` 后分页/流式获取。
- [ ] 评估前端 TypedArray/列式数据,减少嵌套对象和重复复制。
- [ ] 避免后处理中对每个样本重复执行不必要的完整闭合。
- [ ] 记录原始标量数、编码/传输字节数、后处理时间和峰值 RSS。
**验收条件**:
- [ ] 完整输出模式保持现有 API 契约,或通过显式版本升级迁移。
- [ ] 精简模式的变量选择和降采样行为可预测、可测试。
- [ ] `10 s` 基准中后处理时间、传输字节和峰值 RSS 有量化改善。
### OPT-08 进度、取消和服务并发鲁棒性
**目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。
**当前状态**:已有 stream 进度和取消检查;前端无进度阈值约 60 s。本次 2.05 s 附近可见最大间隔约 7.5 s,且中间有接受步与 CPU 活动,因此没有触发真实无进度条件。
**工作项**:
- [ ] 分别上报模拟时间、接受步、内部 RHS/闭合活动和墙钟心跳。
- [ ] 将“运行中但步很慢”与“求解器无活动”使用不同状态和超时策略。
- [ ] 在代数闭合、stream 迭代、Jacobian 构建和后处理内加入有界取消检查。
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。
**验收条件**:
- [ ] 正常慢步不会被误判为死锁,真实无活动能在约定时间内终止并给出诊断。
- [ ] 取消请求在每个主要阶段都能在有界时间内生效。
- [ ] 并发压力下服务仍能响应健康检查和新请求拒绝/排队逻辑。
### OPT-09 建立 10 s 长时验证与模式覆盖
**目标**:用实测替代“0.81 s 或 2.10 s 可以外推到 10 s”的假设。
**当前状态**:`2.10 s` 已成功;`10 s` 尚未运行和建立资源预算。模型可能在后续出现新的事件、模式、接触切换或数值尺度问题。
**工作项**:
- [ ] 在正式锁定环境运行未优化基线 `10 s`,设置心跳、资源上限和可恢复日志。
- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。
**验收条件**:
- [ ] 连续 3 次完成 `10 s`,没有无解释回退、NaN/Inf 或资源失控。
- [ ] 全程模式、事件、关键状态和守恒量满足契约。
- [ ] 可从日志快速判断任何慢区属于积分、Jacobian、闭合、事件还是输出。
### OPT-10 明确高指数 DAE 和强非光滑系统边界
**目标**:明确当前通用求解能力的工程边界,并决定是否值得引入真正的 DAE/互补问题求解器。
**当前状态**:当前架构更适合结构明确、可唯一闭合、状态较连续的规则 index-1 类系统。超硬非光滑接触、临界抖动、近奇异代数系统、更高指数 DAE 和依赖声明不完整的自定义组件仍是薄弱点。
**工作项**:
- [ ] 建立小型基准族:刚性接触、反复开闭、近奇异闭合、尺度跨越、自定义漏依赖和 index-2/3 示例。
- [ ] 对每类系统定义“支持”“降级支持”“明确拒绝”,并给出诊断。
- [ ] 评估质量矩阵 DAE、指数约简、互补/半光滑方法与现有架构的成本。
- [ ] 只有真实模型需求和基准证明必要时,才启动通用 DAE 后端项目。
**验收条件**:
- [ ] 文档与运行时错误能明确说明能力边界,不出现静默错误。
- [ ] 若启动新后端,有独立设计、基准和迁移计划,不与普通 RHS 性能优化混合。
## 6. 统一回归矩阵
| 场景 | 结构 | 数值状态 | 事件/模式 | 回退 | 性能 | 长时内存 |
| --- | --- | --- | --- | --- | --- | --- |
| 小型线性组件 | 必测 | 必测 | 不适用 | 必测 | 冒烟 | 不适用 |
| 非线性压力/流量 | 必测 | 必测 | 可选 | 必测 | 必测 | 可选 |
| stream 无环/成环/反向流 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 |
| 接触与模式切换 | 必测 | 必测 | 必测 | 必测 | 必测 | 可选 |
| 自定义组件与漏依赖 | 必测 | 必测 | 可选 | 必测 | 可选 | 不适用 |
| 本文复杂 XML `0.81 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
当前相关回归套件包括:
- `tests/test_sparse_secant_jacobian.py`
- `tests/test_generic_jacobian_sparsity.py`
- `tests/test_pressure_flow_causal_execution.py`
- `tests/test_stream_pressure_block_solver.py`
- `tests/test_core_solver.py`
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。
## 7. 单项更新模板
完成一个原型或 PR 后,在对应任务下追加以下记录:
```markdown
#### YYYY-MM-DD / <commit-or-branch>
- 状态:未开始 → 进行中 / 部分实现 → 已完成
- 实现范围:
- 未覆盖范围:
- 运行环境:
- 输入与配置:
- 正确性结果:
- 性能结果(中位数与离散度):
- 回退/审计结果:
- 风险或已知退化:
- 决策:合入默认路径 / 继续实验 / 回滚 / 不采用
- 证据文件或 CI 链接:
```
## 8. 总体更新记录
| 日期 | 代码/分支 | 任务 | 变化 | 正确性 | 性能 | 决策 |
| --- | --- | --- | --- | --- | --- | --- |
| 2026-08-17 | `6bb0591d` | 基线 | 原始 `0.81 s` 完成;内存延长 `2.10 s` 完成并越过 2.05 s | 无卡死;当前环境哈希与历史不同,待正式环境复核 | 63.779 s / 126.211 s(单次) | 建立任务清单,先完成 OPT-00 |
| 2026-08-17 | 工作树基于 `6bb0591d`;备份 `backup/jacobian-before-20260817-6bb0591` | OPT-03 | callable sparse Jacobian、真实计数、分段重置、取消、严格 seed 0 与实验 secant | 121 项相关测试通过;另 1 项既有 fixture 路径错误;30 色候选事件不等价,seed 0 候选恢复相同哈希 | 30 色历史候选有收益但不正确;seed 0 候选略慢且缓存 0 命中 | 默认 SciPy;移除多 seed/缓存;保留接入基础;解析/半解析继续后续 |
| 2026-08-17 | 工作树基于 `6bb0591d`;同一备份分支 | OPT-03 首批半解析切片 | exact-columns subset FD、类型化回退/诊断、三活塞 6 列与 34 条因果赋值;31→25 个 FD 颜色;新增 Ideal/PR、PNRP、PNCH012、PNL0001、LSTP、MECMAS 切向原语 | focused 86 + adjacent 164 = 250 项通过;closure 12/13,唯一失败为既有 fixture 路径;局部列对中心 FD 最大相对误差 `1.897e-8`;默认容差轨迹仍超严格逐点门槛,但随 rtol 收紧约 4.67×/5.15× 收敛且事件一致 | `0.81 s` 三次墙钟中位数 61.203→56.708 s,Jac RHS 8096(估计)→5985(实计);最终 `2.10 s` 单次 126.211→116.512 s,正常越过 2.05 s,事件/启动/样本均与基线一致 | 首批目标切片完成,OPT-03 总体仍部分实现;默认 SciPy,`semi-analytic` 显式 opt-in;待通用 stream/其余列、正式锁定环境独立预热和 10 s 验证 |
| 2026-08-17 | 同一 OPT-03 工作树;3 组相邻 A/B | OPT-03 重复性能复核 | 原始 `0.81 s`,每组先 SciPy 后 `semi-analytic`,运行期间无并发仿真负载 | 三组求解统计、哈希、事件和输出网格各自完全稳定;Jacobian RHS 8096(估计)→5985(实计) | 总墙钟中位数 61.203→56.708 s(`-7.34%`),积分中位数 59.725→55.631 s(`-6.85%`) | 保持显式 opt-in;仍需正式锁定环境独立预热、严格轨迹契约和 10 s 验证 |
## 9. 相关文档
- [后端求解逻辑与效率优化调研](./后端求解逻辑与效率优化调研.md)
- [仿真性能评估-2026-08-15](./仿真性能评估-2026-08-15.md)
- [文档目录说明](../README.md)
@@ -111,7 +111,7 @@ System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用
`modelVersion` 必须与当前注册模型完全一致。版本不一致时返回 `modelVersion` 必须与当前注册模型完全一致。版本不一致时返回
`COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见 `COMPONENT_MODEL_VERSION_MISMATCH`,不会静默使用当前模型解释旧输入。完整结构见
`docs/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。 `docs/standard/system-xml-v3.md` 和 `schemas/system-simulation-v3.xsd`。
## 6. HTTP API ## 6. HTTP API
@@ -247,7 +247,7 @@ class ExampleComponent(Component):
7. 模型的方程不能依赖图标方向、界面分类或画布位置。 7. 模型的方程不能依赖图标方向、界面分类或画布位置。
完整方程示例参见 完整方程示例参见
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。 [`app/simulation/components/example.md`](../../app/simulation/components/example.md)。
## 7. 界面显示声明 ## 7. 界面显示声明
@@ -47,7 +47,7 @@
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。 应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
当前示例是 当前示例是
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。 [`app/simulation/examples/testmodel/dynamic_pipe.py`](../../app/simulation/examples/testmodel/dynamic_pipe.py)。
### 2.4 新增物理域 ### 2.4 新增物理域
@@ -70,11 +70,11 @@
1. 本文档。 1. 本文档。
2. 目标库的 `library.py`。 2. 目标库的 `library.py`。
3. 同分类中物理行为最接近的现有模型。 3. 同分类中物理行为最接近的现有模型。
4. [`core/base.py`](../app/simulation/core/base.py)。 4. [`core/base.py`](../../app/simulation/core/base.py)。
5. [`core/ports.py`](../app/simulation/core/ports.py)。 5. [`core/ports.py`](../../app/simulation/core/ports.py)。
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。 6. [`core/metadata.py`](../../app/simulation/core/metadata.py)。
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。 7. [`core/catalog.py`](../../app/simulation/core/catalog.py)。
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。 8. [`registry.py`](../../app/simulation/registry.py) 中的启动校验。
9. 与目标模型最接近的测试。 9. 与目标模型最接近的测试。
不要只根据文件名、前端图标或旧 XML 猜测模型语义。 不要只根据文件名、前端图标或旧 XML 猜测模型语义。
@@ -564,11 +564,11 @@ class ExampleRestriction(AlgebraicComponent):
真实现有模型可参考: 真实现有模型可参考:
- 储能元件: - 储能元件:
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py) [`cylinder.py`](../../app/simulation/components/experimental/storage/cylinder.py)
- 阻性元件: - 阻性元件:
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py) [`orifice.py`](../../app/simulation/components/experimental/flow/orifice.py)
- 多端口连接元件: - 多端口连接元件:
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py) [`tee.py`](../../app/simulation/components/experimental/junctions/tee.py)
## 14. 注册模型 ## 14. 注册模型
@@ -2,7 +2,7 @@
System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。 System XML v3 是 SystemSimulationApp 当前唯一的 XML 求解输入格式。它只描述可执行模型,不再承担 ReactFlow 画布存档职责。
机器可读结构见 [`schemas/system-simulation-v3.xsd`](../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。 机器可读结构见 [`schemas/system-simulation-v3.xsd`](../../schemas/system-simulation-v3.xsd)。当前校验、解析、编译和仿真接口固定按 v3 处理,不会根据 `schemaVersion` 自动切换到 v1 或 v2。
## 1. 设计边界 ## 1. 设计边界
File renamed without changes.
@@ -0,0 +1,53 @@
# 更新日志 2026-08-17
## 11:15
- 复测复杂系统 XML:磁盘文件的终止时间为 `0.81 s`,原样运行正常完成;仅在内存中延长至 `2.10 s` 后也正常越过此前报告的 `2.05 s` 慢区并结束,没有发生重试、回退或无进度卡死。
- 性能分析确认约 97% 时间消耗在积分阶段,有限差分 Jacobian 估计约占 RHS 工作量的 69%;后续优化重点确定为半解析 Jacobian、扁平数值中间表示和 stream/物性传播,而不是继续优先优化未触发的全局非线性求解。
- Jacobian、因果快路径、stream 方程块和逐步积分相关的 44 项回归测试通过;`10 s` 长时运行尚未验证,不能由 `2.10 s` 结果外推保证。
## 11:18
- 重构 `docs/README.md`,明确 `update-log/`、`standard/`、`other/` 三类目录职责,并建立按日期命名、按实际完成时间分段的更新日志规范与模板。
## 11:28
- 完成求解器架构复核:当前 472 个代数未知量已形成覆盖 200 个 flow/force 未知量的因果赋值计划,复杂模型运行中因果快路径审计没有失败或回退;确认下一阶段应在现有计划之上建设可求导、可数组化的数值内核,而不是重复实现因果求解器。
## 11:30
- 精简文档索引,删除容易过期的专题清单、代码入口和单日日志链接,只保留目录职责与更新日志规范;明确历史工作缺少准确完成时间时不得猜测补写。
## 11:35
- 新增 `docs/other/求解器性能优化任务清单.md`,记录 `0.81 s`、`2.10 s` 基线,建立 OPT-00~OPT-10 优化任务、验收标准、回滚要求、性能对比口径以及 `10 s` 长时验证计划。
## 11:38
- 修复文档重新分类产生的 18 条 Markdown 断链、4 处旧路径文字和 1 处易失效的行号引用;保留历史日志原文不变,全仓本地 Markdown 相对链接复扫为 0 条断链,`git diff --check` 通过。
## 15:04
- 新增 callable 稀疏 Jacobian 数值层,并为 BDF/Radau 接入真实 RHS 计数、求解分段重建、取消检查、诊断统计和类型化完整数值回退;显式积分器继续忽略 Jacobian。
- 完成三条活塞支路 6 个机械状态列的首批半解析传播,覆盖 34 条因果赋值,并增加 Ideal/Peng–Robinson 介质及 PNRP17、PNCH012、PNL0001、LSTP00A、MECMAS21 等组件的局部切向原语;有限差分颜色数由 31 降至 25。
- 新路径仅在 `SIMULATION_ODE_JACOBIAN_MODE=semi-analytic` 时显式启用,默认仍使用 SciPy;无法证明拓扑、进入非光滑边界或局部导数不可用时恢复完整数值 Jacobian,不会把未知导数静默填为零。
- 相关测试共 250 项通过;局部半解析列相对独立中心差分的最大相对误差为 `1.897e-8`。另有一项热流体闭合测试因测试资源已移动而仍读取旧路径失败,与本次 Jacobian 修改无关。
- `0.81 s` 三组同机对比中,实验路径总墙钟中位数由 `61.203 s` 降至 `56.708 s`,Jacobian RHS 减少约 26.1%;`2.10 s` 单次由 `126.211 s` 降至 `116.512 s`,事件、启动和样本数量保持一致并正常越过 `2.05 s`。
- 默认容差下两条路径尚未满足拟定的严格逐点轨迹门槛,且通用 stream 导数、自定义组件、正式锁定环境复测和 `10 s` 长时覆盖尚未完成,因此 OPT-03 仍为部分实现,没有切换生产默认路径。
## 15:14
- 将后端、前端和一键启动入口统一到 `bat/` 目录,保留三组 Windows `.bat` 脚本,并新增一一对应的 Linux `.sh` 脚本;删除仓库根目录和 `frontend/` 下已被替代的旧启动入口。
- Windows 脚本改为从自身位置解析仓库路径,分别使用 `.venv-win` 和仓库内兼容的便携 Node.js;Linux 脚本使用 `.venv`,并优先选择 `.tools/node-*-linux-x64`,缺少环境、依赖或兼容 Node.js 时会给出明确提示。
- Linux 一键启动脚本可在同一终端管理 FastAPI 与 Vite;收到 `Ctrl+C`、终止信号或任一服务退出时会清理两个进程组,超时后仅强制结束尚未退出的进程,避免遗留后台服务。
- 新增脚本换行规则并设置 Linux 脚本可执行权限;本地 Linux Node.js 运行时加入忽略规则,避免 `.venv`、`node_modules` 和便携工具污染版本库。
- 根 `README.md` 补充 Windows/Linux 环境准备、Node.js 版本要求、六个启动命令、服务地址及停止方式,相关技术文档中的旧启动路径同步为 `bat/` 下的新入口。
- 完成本机开发环境准备:创建 Python `.venv` 并按 `requirements.txt` 安装依赖,`pip check` 无冲突;安装项目本地 Node.js `v24.18.0`、npm `11.16.0`,并通过 `npm ci` 安装前端依赖。
- Linux 脚本语法、启动和进程清理验证通过;FastAPI `8000` 与 Vite `5173` 均返回 HTTP 200,测试结束后端口无残留监听。Windows 脚本已完成静态检查,尚未在 Windows 实机运行。
- npm 审计报告 2 个高危依赖问题;本次未执行可能改变锁定依赖版本的自动修复,留待单独评估处理。
## 15:27
- 根据同日其他项目会话的最终记录补全上述文档管理、性能诊断和求解器优化工作,并与当前源码、测试及优化任务账本交叉核对。
- 在当前项目 `.venv` 中重新运行 Jacobian、切向原语、三活塞、core solver、稀疏结构和 XML 仿真的定向测试,共 86 项全部通过;`git diff --check` 通过。
- 热流体闭合套件当前仍为 12/13,通过项不受影响;唯一失败是测试继续读取已经移动的旧 fixture 路径。测试资源移动属于用户操作,本日志未将其计入其他会话的完成成果。
-1
View File
@@ -1 +0,0 @@
powershell -ExecutionPolicy Bypass -File "%~dp0start-dev.ps1"
-14
View File
@@ -1,14 +0,0 @@
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $PSScriptRoot
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
Sort-Object Name -Descending |
Select-Object -First 1
if (-not $nodeDir) {
throw "Node.js portable runtime was not found under .tools."
}
$env:Path = "$($nodeDir.FullName);$env:Path"
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
-24
View File
@@ -1,24 +0,0 @@
@echo off
setlocal
cd /d "%~dp0"
title SystemSimulationApp Launcher
if not exist "%~dp0start-backend.bat" (
echo [ERROR] start-backend.bat was not found.
pause
exit /b 1
)
if not exist "%~dp0start-reactflow.bat" (
echo [ERROR] start-reactflow.bat was not found.
pause
exit /b 1
)
echo Starting FastAPI and ReactFlow in separate windows...
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%~dp0start-backend.bat"
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%~dp0start-reactflow.bat"
exit /b 0
-31
View File
@@ -1,31 +0,0 @@
@echo off
setlocal
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
set "FRONTEND_DIR=%~dp0frontend"
set "START_SCRIPT=%FRONTEND_DIR%\start-dev.bat"
if not exist "%START_SCRIPT%" (
echo [ERROR] ReactFlow start script was not found:
echo %START_SCRIPT%
echo.
pause
exit /b 1
)
echo Starting ReactFlow at http://127.0.0.1:5173
echo Press Ctrl+C to stop the service.
echo.
cd /d "%FRONTEND_DIR%"
call "%START_SCRIPT%"
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" (
echo.
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
pause
)
exit /b %EXIT_CODE%
File diff suppressed because it is too large. Load diff
File renamed without changes.
Internal Server Error - 奔熠云-Git
500 Internal Server Error

An error occurred:

An error occurred

Gitea Version: 28.0.0