diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..faa300a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.bat text eol=crlf +*.cmd text eol=crlf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 5f91e45..3e8914e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ htmlcov/ # Local virtual environments .venv/ .venv-win/ + +# Local Linux toolchain (downloaded for the startup scripts) +.tools/node-*-linux-x64/ + app/data/ frontend/node_modules/ frontend/dist/ diff --git a/README.md b/README.md index 3f77507..fb81b80 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,67 @@ 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 启动时自动加载。 @@ -19,19 +80,14 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。 当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。 -XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用: - -```powershell -.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt -``` +XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。 ## 文档 - [开发文档索引](docs/README.md) -- [后端接口版本与定义规范 v1](docs/backend-interface-version-spec-v1.md) -- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md) -- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md) +- [后端接口版本与定义规范 v1](docs/standard/backend-interface-version-spec-v1.md) +- [组件模型建模规范 v1](docs/standard/component-model-authoring-spec-v1.md) +- [组件库分类、发现与读取规范 v1](docs/standard/component-library-spec-v1.md) - [组件目录 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) - diff --git a/app/simulation/README.md b/app/simulation/README.md index 257008a..6de5c25 100644 --- a/app/simulation/README.md +++ b/app/simulation/README.md @@ -33,8 +33,8 @@ FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件 公开临时库入口是 `components/amesim/library.py`。公开模型必须在 模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS / RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见 -[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和 -[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。 +[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和 +[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。 当前关键文件: @@ -88,7 +88,7 @@ Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行 `diagnostics.performance.propertyCache` 中返回。 基准原始 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) 当前四个主变量的最大误差为: diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index ad8911a..8adb2b4 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -1,8 +1,9 @@ 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 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 ( AMESIM_GAS_INDEX_PARAMETER, @@ -22,11 +23,35 @@ from app.simulation.core.metadata import ( ResultVariableDefinition, 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.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 @@ -829,6 +854,105 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): ) 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]: props = self.properties() flow = self.mass_flow(self.port_1.p, props.p, props.T) @@ -913,6 +1037,98 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): ) 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): """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" diff --git a/app/simulation/components/amesim/mechanical/pistons.py b/app/simulation/components/amesim/mechanical/pistons.py index a018dc4..e8da319 100644 --- a/app/simulation/components/amesim/mechanical/pistons.py +++ b/app/simulation/components/amesim/mechanical/pistons.py @@ -1,7 +1,8 @@ from __future__ import annotations -from collections.abc import Mapping -from math import pi +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from math import isfinite, pi from app.simulation.components.amesim.gases import ( AMESIM_GAS_INDEX_PARAMETER, @@ -18,6 +19,18 @@ from app.simulation.core.ports import PortDefinition 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): """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]]: 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: self.port_1.h_outflow = connected_h.get( "port_1", diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py index fb73ef1..2b0243d 100644 --- a/app/simulation/components/amesim/mechanical/translational.py +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -1,7 +1,8 @@ from __future__ import annotations -from collections.abc import Mapping -from math import expm1 +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from math import expm1, isfinite from app.simulation.core.base import AlgebraicComponent, DynamicComponent from app.simulation.core.catalog import ( @@ -20,6 +21,24 @@ from app.simulation.core.medium import IdealGasMedium 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_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0)) _MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0)) @@ -723,6 +742,204 @@ class AmesimMecmas21(DynamicComponent): ) 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]: return { "a": self.acceleration(), @@ -979,6 +1196,112 @@ class AmesimLstp00a(AlgebraicComponent): ) 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: self._causal_penetration = None self._causal_contact_force = None diff --git a/app/simulation/components/amesim/media/mediums.py b/app/simulation/components/amesim/media/mediums.py index 75880ad..8618e13 100644 --- a/app/simulation/components/amesim/media/mediums.py +++ b/app/simulation/components/amesim/media/mediums.py @@ -1,7 +1,8 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass +from math import isfinite from typing import ClassVar from app.simulation.core.errors import RecoverableTrialStateError @@ -9,6 +10,8 @@ from app.simulation.core.medium import ( GasMedium, IdealGasMedium, ThermodynamicProperties, + ThermodynamicPropertiesLinearization, + ThermodynamicPropertyTangents, ) from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid 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) class AmesimGasPropertyModelSpec: diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index 15af67f..6bd1594 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -1,6 +1,8 @@ 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 ( AMESIM_GAS_INDEX_PARAMETER, @@ -14,11 +16,24 @@ from app.simulation.core.metadata import ( ResultVariableDefinition, 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.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): """AMESim PNCH023 simple pneumatic chamber with heat exchange. @@ -518,6 +533,132 @@ class AmesimPnch012(ThermodynamicVolumeComponent): energy_derivative -= props.p * self.total_volume_rate() 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, ...]: pressure = self.medium.properties_from_mU( self.state.m, diff --git a/app/simulation/components/example.md b/app/simulation/components/example.md index a20f8f6..d9ad7aa 100644 --- a/app/simulation/components/example.md +++ b/app/simulation/components/example.md @@ -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. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。 组件库、分类和自动发现的完整规则参见 -[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。 +[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。 diff --git a/app/simulation/core/medium.py b/app/simulation/core/medium.py index f1210a1..e394fe8 100644 --- a/app/simulation/core/medium.py +++ b/app/simulation/core/medium.py @@ -1,7 +1,8 @@ from __future__ import annotations 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.performance import profile_property @@ -16,6 +17,36 @@ class ThermodynamicProperties: 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): """Thermodynamic contract required by pneumatic components. @@ -75,6 +106,18 @@ class GasMedium(Protocol): V: float, ) -> 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) class IdealGasMedium: @@ -224,3 +267,100 @@ class IdealGasMedium: u = U / m h = self.specific_enthalpy(T) 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", + ) diff --git a/app/simulation/solvers/jacobian.py b/app/simulation/solvers/jacobian.py new file mode 100644 index 0000000..af560a1 --- /dev/null +++ b/app/simulation/solvers/jacobian.py @@ -0,0 +1,1478 @@ +"""Audited sparse secant Jacobians for implicit ODE solvers. + +This module deliberately has no dependency on ``GenericFluidSystem`` or the +SciPy solver wrappers. It is an experimental, opt-in numerical kernel that can +be wired into BDF/Radau through their callable ``jac`` argument after its model +level eligibility rules have been checked. + +The first Jacobian in every solver segment is a complete, sparsity-coloured +finite-difference build. Ordinary RHS samples at the same time coordinate can +then update that matrix with row-constrained sparse secants. At most one such +matrix is reused before another complete build, and reuse is allowed only after +a deterministic directional finite-difference audit. An audit mismatch falls +back to a complete build in the same call. + +SciPy's private ``num_jac`` and ``group_columns`` helpers are isolated here so +an incompatible SciPy version fails with one explicit compatibility error +rather than changing solver behaviour silently. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +import inspect +import math +from time import perf_counter +from typing import TypeAlias + +import numpy as np + +from app.simulation.core.errors import RecoverableTrialStateError + + +JacobianEvaluate: TypeAlias = Callable[[float, np.ndarray], Sequence[float]] +ExactRowValues: TypeAlias = Sequence[float] | Mapping[int, float] +ExactRow: TypeAlias = ExactRowValues | Callable[[float, np.ndarray], ExactRowValues] +ExactColumnValues: TypeAlias = Sequence[float] | np.ndarray +ExactColumnProviderResult: TypeAlias = ( + Sequence[Sequence[float]] | np.ndarray | Mapping[int, ExactColumnValues] +) +ExactColumnProvider: TypeAlias = Callable[ + [float, np.ndarray, tuple[int, ...]], + ExactColumnProviderResult, +] +ExactColumns: TypeAlias = ( + tuple[Sequence[int], ExactColumnProvider] + | Mapping[int, ExactColumnValues] +) + + +class SparseJacobianCompatibilityError(RuntimeError): + """Raised when the installed SciPy cannot provide the expected helpers.""" + + +class ExactColumnsUnavailable(RuntimeError): + """Request a numerical-column fallback for one Jacobian assembly. + + An exact-column provider may raise this exception when its analytical or + tangent calculation is not applicable at the current primal point. The + provider must raise before mutating the shared model closure: the Jacobian + builder deliberately reuses the real base RHS evaluation that immediately + preceded the provider call. + + Shape errors, non-finite values, and all other provider exceptions remain + programming errors and are intentionally not converted into a fallback. + """ + + def __init__(self, reason: str) -> None: + self.reason = str(reason) + super().__init__(self.reason) + + +class SparseSecantJacobian: + """Build and cautiously reuse a sparse numerical ODE Jacobian. + + Parameters + ---------- + evaluate: + Scalar-state RHS evaluator. The callable receives ``(time, state)`` + and must return one derivative per state. It need not be vectorized. + sparsity: + Conservative square Jacobian structure accepted by ``scipy.sparse``. + Missing structural entries cannot be repaired by the secant update. + atol: + Scalar or state-aligned absolute tolerance used by SciPy's numerical + Jacobian step selection. + exact_rows: + Optional complete row providers keyed by row index. A provider (or + constant value) may return either a dense row or a ``column: value`` + mapping. Exact rows are excluded from finite differences and secant + updates, then refreshed at the requested ``(time, state)``. + exact_columns: + Optional ``(column_indexes, provider)`` pair. The provider receives + ``(time, state, normalized_column_indexes)`` and returns a dense + ``(state_count, column_count)`` matrix in that exact column order. An + ordered ``column: dense_column`` mapping is also accepted from the + provider so its returned order can be checked. A fixed mapping may be + supplied directly as a convenience. Exact columns are removed before + seed-0 finite-difference colouring, then complete columns and complete + rows are applied in that order, so ``exact_rows`` wins at intersections. + A callable provider may raise :class:`ExactColumnsUnavailable` before + changing the shared model closure to request a one-build fallback to + the original seed-0 numerical Jacobian. + audit_relative_tolerance: + Maximum component-wise relative mismatch for the deterministic Jv + audit. The audit also includes a floating-point roundoff allowance. + audit_absolute_tolerance: + Optional absolute allowance for the Jv delta comparison. + max_consecutive_reuses: + ``0`` selects complete seed-0 finite-difference builds and disables + observation work. ``1`` enables the experimental audited secant path. + + Notes + ----- + ``observe`` must receive only ordinary RHS evaluations. Calls made by + this object while building or auditing the Jacobian are suppressed + automatically if the supplied evaluator itself reports them back through + ``observe``. + """ + + _MAX_PENDING_SECANT_PAIRS = 4 + # Keep SciPy's deterministic compatibility baseline. Alternative greedy + # orders can change finite-difference perturbations and therefore event + # sequences in non-smooth models, even if they use fewer colour groups. + _COLORING_SEED = 0 + + def __init__( + self, + evaluate: JacobianEvaluate, + sparsity: object, + atol: float | Sequence[float], + exact_rows: Mapping[int, ExactRow] | None = None, + exact_columns: ExactColumns | None = None, + *, + audit_relative_tolerance: float = 5.0e-2, + audit_absolute_tolerance: float = 0.0, + max_consecutive_reuses: int = 1, + ) -> None: + try: + from scipy.sparse import csc_matrix + except ImportError as exc: # pragma: no cover - project requires SciPy. + raise SparseJacobianCompatibilityError( + "Sparse secant Jacobians require scipy.sparse." + ) from exc + + if not callable(evaluate): + raise TypeError("evaluate must be callable.") + self._evaluate_rhs = evaluate + + structure = csc_matrix(sparsity, dtype=bool) + structure.eliminate_zeros() + if len(structure.shape) != 2 or structure.shape[0] != structure.shape[1]: + raise ValueError("Jacobian sparsity must be a square matrix.") + if structure.shape[0] == 0: + raise ValueError("Jacobian sparsity must contain at least one state.") + self._state_count = int(structure.shape[0]) + tolerance = np.asarray(atol, dtype=float) + if tolerance.ndim == 0: + tolerance = np.full(self._state_count, float(tolerance), dtype=float) + if tolerance.shape != (self._state_count,): + raise ValueError( + "atol must be scalar or contain one value per Jacobian state." + ) + if not np.all(np.isfinite(tolerance)) or np.any(tolerance <= 0.0): + raise ValueError("atol values must be finite and greater than zero.") + self._atol = tolerance + + if ( + not math.isfinite(audit_relative_tolerance) + or audit_relative_tolerance < 0.0 + ): + raise ValueError( + "audit_relative_tolerance must be finite and non-negative." + ) + if ( + not math.isfinite(audit_absolute_tolerance) + or audit_absolute_tolerance < 0.0 + ): + raise ValueError( + "audit_absolute_tolerance must be finite and non-negative." + ) + self._audit_relative_tolerance = float(audit_relative_tolerance) + self._audit_absolute_tolerance = float(audit_absolute_tolerance) + if ( + not isinstance(max_consecutive_reuses, int) + or isinstance(max_consecutive_reuses, bool) + or max_consecutive_reuses not in {0, 1} + ): + raise ValueError("max_consecutive_reuses must be either 0 or 1.") + self._max_consecutive_reuses = max_consecutive_reuses + + self._exact_rows = dict(exact_rows or {}) + for row_index in self._exact_rows: + if ( + not isinstance(row_index, int) + or isinstance(row_index, bool) + or not 0 <= row_index < self._state_count + ): + raise ValueError( + f"Exact Jacobian row index {row_index!r} is out of range." + ) + self._exact_row_indexes = frozenset(self._exact_rows) + + self._exact_column_provider: ExactColumnProvider | None = None + self._fixed_exact_column_values: np.ndarray | None = None + if exact_columns is None: + exact_column_indexes: tuple[int, ...] = () + elif isinstance(exact_columns, Mapping): + exact_column_indexes = self._normalize_exact_column_indexes( + exact_columns.keys() + ) + fixed_columns: list[np.ndarray] = [] + for column_index in exact_column_indexes: + column = np.asarray(exact_columns[column_index], dtype=float) + if column.shape != (self._state_count,): + raise ValueError( + f"Exact Jacobian column {column_index} must have shape " + f"{(self._state_count,)}, received {column.shape}." + ) + if not np.all(np.isfinite(column)): + raise ValueError( + "Exact Jacobian columns must contain finite values." + ) + fixed_columns.append(column.copy()) + self._fixed_exact_column_values = ( + np.column_stack(fixed_columns) + if fixed_columns + else np.empty((self._state_count, 0), dtype=float) + ) + else: + if not isinstance(exact_columns, tuple) or len(exact_columns) != 2: + raise TypeError( + "exact_columns must be a fixed mapping or a " + "(column_indexes, provider) pair." + ) + requested_columns, provider = exact_columns + if not callable(provider): + raise TypeError("The exact Jacobian column provider must be callable.") + exact_column_indexes = self._normalize_exact_column_indexes( + requested_columns + ) + self._exact_column_provider = provider + self._exact_column_indexes = exact_column_indexes + self._exact_column_index_set = frozenset(exact_column_indexes) + self._exact_column_pattern = np.asarray( + structure[:, list(exact_column_indexes)].toarray(), + dtype=bool, + ) + self._exact_column_outside_pattern_nonzero_count = 0 + + self._original_sparsity = structure.copy() + finite_difference_sparsity = structure.tolil(copy=True) + for row_index in self._exact_row_indexes: + finite_difference_sparsity.rows[row_index] = [] + finite_difference_sparsity.data[row_index] = [] + self._original_finite_difference_sparsity = ( + finite_difference_sparsity.tocsc().astype(bool) + ) + self._remaining_finite_difference_sparsity = ( + self._original_finite_difference_sparsity.copy() + ) + for column_index in self._exact_column_indexes: + start = int( + self._remaining_finite_difference_sparsity.indptr[column_index] + ) + stop = int( + self._remaining_finite_difference_sparsity.indptr[ + column_index + 1 + ] + ) + self._remaining_finite_difference_sparsity.data[start:stop] = False + self._remaining_finite_difference_sparsity.eliminate_zeros() + remaining_columns = np.asarray( + self._remaining_finite_difference_sparsity.getnnz(axis=0) + ).reshape(-1) > 0 + self._remaining_finite_difference_columns = np.flatnonzero( + remaining_columns + ) + self._finite_difference_column_count = int( + self._remaining_finite_difference_columns.size + ) + + self._num_jac, group_columns = self._load_scipy_helpers() + if self._exact_column_indexes: + self._validate_subset_sparse_compatibility() + self._original_groups = np.asarray( + group_columns( + self._original_sparsity, + order=self._COLORING_SEED, + ), + dtype=int, + ) + if self._original_groups.shape != (self._state_count,): + raise SparseJacobianCompatibilityError( + "SciPy Jacobian column grouping returned an unexpected shape." + ) + self._original_color_group_count = ( + int(self._original_groups.max(initial=-1)) + 1 + ) + + if self._exact_column_indexes: + # True subset finite differences: exact and otherwise inactive + # columns are absent from both colouring and perturbation batches. + grouped_columns = self._remaining_finite_difference_columns + grouping_structure = self._remaining_finite_difference_sparsity[ + :, grouped_columns + ] + self._remaining_groups = np.full( + self._state_count, + -1, + dtype=int, + ) + else: + # Preserve the established SciPy seed-0 baseline when no exact + # columns are requested, including the existing exact-row policy. + grouped_columns = np.empty(0, dtype=int) + grouping_structure = None + self._remaining_groups = self._original_groups.copy() + if grouped_columns.size: + remaining_groups = np.asarray( + group_columns( + grouping_structure, + order=self._COLORING_SEED, + ), + dtype=int, + ) + if remaining_groups.shape != (grouped_columns.size,): + raise SparseJacobianCompatibilityError( + "SciPy Jacobian column grouping returned an unexpected shape." + ) + self._remaining_groups[grouped_columns] = remaining_groups + self._remaining_color_group_count = ( + int(remaining_groups.max(initial=-1)) + 1 + ) + elif self._exact_column_indexes: + self._remaining_color_group_count = 0 + else: + self._remaining_color_group_count = self._original_color_group_count + if self._remaining_groups.shape != (self._state_count,): + raise SparseJacobianCompatibilityError( + "SciPy Jacobian column grouping returned an unexpected shape." + ) + self._coloring_seed = self._COLORING_SEED + self._color_group_count = self._remaining_color_group_count + self._default_color_group_count = self._remaining_color_group_count + + self._jacobian = None + self._original_factor = None + self._remaining_factor = None + self._last_factor = None + self._constructing = False + self._has_secant_update = False + self._consecutive_reuses = 0 + self._pending_secants: list[ + tuple[np.ndarray, np.ndarray, np.ndarray] + ] = [] + self._last_observation_time: float | None = None + self._last_observation_state: np.ndarray | None = None + self._last_observation_rhs: np.ndarray | None = None + + self._full_build_count = 0 + self._secant_reuse_count = 0 + self._audit_failure_count = 0 + self._finite_difference_rhs_count = 0 + self._base_rhs_count = 0 + self._jv_audit_count = 0 + self._secant_update_count = 0 + self._rejected_observation_count = 0 + self._segment_start_count = 0 + self._jacobian_evaluation_count = 0 + self._assembly_seconds = 0.0 + self._exact_column_build_count = 0 + self._exact_column_fallback_count = 0 + self._last_exact_column_fallback_reason: str | None = None + self._last_build_finite_difference_rhs_count = 0 + self._last_decision = "uninitialized" + self._last_audit_relative_error: float | None = None + self._last_audit_rms_relative_error: float | None = None + self._last_audit_p95_relative_error: float | None = None + self._segments: list[dict[str, int | float]] = [] + + def _normalize_exact_column_indexes( + self, + values: Sequence[int] | object, + ) -> tuple[int, ...]: + try: + requested = tuple(values) # type: ignore[arg-type] + except TypeError as exc: + raise TypeError( + "Exact Jacobian column indexes must be an iterable of integers." + ) from exc + seen: set[int] = set() + for column_index in requested: + if ( + not isinstance(column_index, int) + or isinstance(column_index, bool) + or not 0 <= column_index < self._state_count + ): + raise ValueError( + f"Exact Jacobian column index {column_index!r} is out of range." + ) + if column_index in seen: + raise ValueError( + f"Exact Jacobian column index {column_index} is duplicated." + ) + seen.add(column_index) + return tuple(sorted(seen)) + + @staticmethod + def _load_scipy_helpers(): + try: + from scipy.integrate._ivp.common import num_jac + from scipy.optimize._numdiff import group_columns + except (ImportError, AttributeError) as exc: + raise SparseJacobianCompatibilityError( + "The installed SciPy does not expose compatible numerical " + "Jacobian helpers." + ) from exc + + expected_num_jac = { + "fun", + "t", + "y", + "f", + "threshold", + "factor", + "sparsity", + } + expected_group_columns = {"A", "order"} + try: + num_jac_parameters = set(inspect.signature(num_jac).parameters) + group_parameters = set(inspect.signature(group_columns).parameters) + except (TypeError, ValueError) as exc: + raise SparseJacobianCompatibilityError( + "Unable to inspect SciPy numerical Jacobian helper signatures." + ) from exc + if not expected_num_jac.issubset(num_jac_parameters) or not ( + expected_group_columns.issubset(group_parameters) + ): + raise SparseJacobianCompatibilityError( + "The installed SciPy numerical Jacobian helper API is unsupported." + ) + return num_jac, group_columns + + @staticmethod + def _validate_subset_sparse_compatibility() -> None: + """Fail before integration when subset-FD private helpers are absent.""" + + try: + from scipy.integrate._ivp.common import ( + EPS, + NUM_JAC_DIFF_BIG, + NUM_JAC_DIFF_REJECT, + NUM_JAC_DIFF_SMALL, + NUM_JAC_FACTOR_DECREASE, + NUM_JAC_FACTOR_INCREASE, + NUM_JAC_MIN_FACTOR, + ) + from scipy.sparse import coo_matrix, csc_matrix, find + except (ImportError, AttributeError) as exc: + raise SparseJacobianCompatibilityError( + "The installed SciPy does not expose compatible subset " + "Jacobian helpers." + ) from exc + del ( + EPS, + NUM_JAC_DIFF_BIG, + NUM_JAC_DIFF_REJECT, + NUM_JAC_DIFF_SMALL, + NUM_JAC_FACTOR_DECREASE, + NUM_JAC_FACTOR_INCREASE, + NUM_JAC_MIN_FACTOR, + coo_matrix, + csc_matrix, + find, + ) + + def _subset_sparse_num_jac( + self, + fun, + time: float, + state: np.ndarray, + base_rhs: np.ndarray, + ): + """Run SciPy-compatible adaptive differences on active columns only.""" + + try: + from scipy.integrate._ivp.common import ( + EPS, + NUM_JAC_DIFF_BIG, + NUM_JAC_DIFF_REJECT, + NUM_JAC_DIFF_SMALL, + NUM_JAC_FACTOR_DECREASE, + NUM_JAC_FACTOR_INCREASE, + NUM_JAC_MIN_FACTOR, + ) + from scipy.sparse import coo_matrix, csc_matrix, find + except (ImportError, AttributeError) as exc: # pragma: no cover + raise SparseJacobianCompatibilityError( + "The installed SciPy does not expose compatible numerical " + "Jacobian constants." + ) from exc + + active_columns = self._remaining_finite_difference_columns + active_count = int(active_columns.size) + if active_count == 0: # pragma: no cover - guarded by _full_build. + return csc_matrix( + (self._state_count, self._state_count), dtype=float + ), self._remaining_factor + + if self._remaining_factor is None: + factor = np.full(self._state_count, EPS**0.5, dtype=float) + else: + factor = np.asarray(self._remaining_factor, dtype=float).copy() + if factor.shape != (self._state_count,): + raise SparseJacobianCompatibilityError( + "SciPy numerical Jacobian factor has an unexpected shape." + ) + + rhs_sign = 2.0 * (np.real(base_rhs) >= 0.0).astype(float) - 1.0 + state_scale = rhs_sign * np.maximum(self._atol, np.abs(state)) + step = np.zeros(self._state_count, dtype=float) + step[active_columns] = ( + state[active_columns] + + factor[active_columns] * state_scale[active_columns] + ) - state[active_columns] + for column_index in active_columns[step[active_columns] == 0.0]: + while step[column_index] == 0.0: + factor[column_index] *= 10.0 + step[column_index] = ( + state[column_index] + + factor[column_index] * state_scale[column_index] + ) - state[column_index] + + groups = self._remaining_groups[active_columns] + group_count = self._remaining_color_group_count + perturbations = np.zeros((self._state_count, group_count), dtype=float) + perturbations[active_columns, groups] = step[active_columns] + perturbed_rhs = fun(time, state[:, None] + perturbations) + differences = perturbed_rhs - base_rhs[:, None] + + reduced_structure = self._remaining_finite_difference_sparsity[ + :, active_columns + ] + row_indexes, reduced_columns, _values = find(reduced_structure) + reduced_jacobian = coo_matrix( + ( + differences[row_indexes, groups[reduced_columns]], + (row_indexes, reduced_columns), + ), + shape=(self._state_count, active_count), + ).tocsc() + maximum_rows = np.asarray( + abs(reduced_jacobian).argmax(axis=0) + ).ravel() + reduced_range = np.arange(active_count) + maximum_difference = np.asarray( + np.abs(reduced_jacobian[maximum_rows, reduced_range]) + ).ravel() + difference_scale = np.maximum( + np.abs(base_rhs[maximum_rows]), + np.abs(perturbed_rhs[maximum_rows, groups]), + ) + + too_small = maximum_difference < NUM_JAC_DIFF_REJECT * difference_scale + if np.any(too_small): + rejected_reduced_columns = np.flatnonzero(too_small) + rejected_columns = active_columns[rejected_reduced_columns] + increased_factor = ( + NUM_JAC_FACTOR_INCREASE * factor[rejected_columns] + ) + increased_step = ( + state[rejected_columns] + + increased_factor * state_scale[rejected_columns] + ) - state[rejected_columns] + increased_step_by_column = np.zeros(self._state_count, dtype=float) + increased_step_by_column[rejected_columns] = increased_step + + rejected_groups = np.unique(groups[rejected_reduced_columns]) + group_map = np.full(group_count, -1, dtype=int) + retry_perturbations = np.zeros( + (self._state_count, rejected_groups.size), + dtype=float, + ) + for retry_index, group in enumerate(rejected_groups): + group_map[group] = retry_index + group_columns = active_columns[groups == group] + retry_perturbations[group_columns, retry_index] = ( + increased_step_by_column[group_columns] + ) + + retry_rhs = fun(time, state[:, None] + retry_perturbations) + retry_differences = retry_rhs - base_rhs[:, None] + retry_structure = reduced_structure[:, rejected_reduced_columns] + retry_rows, retry_columns, _retry_values = find(retry_structure) + retry_group_columns = rejected_reduced_columns[retry_columns] + retry_jacobian = coo_matrix( + ( + retry_differences[ + retry_rows, + group_map[groups[retry_group_columns]], + ], + (retry_rows, retry_columns), + ), + shape=(self._state_count, rejected_reduced_columns.size), + ).tocsc() + retry_maximum_rows = np.asarray( + abs(retry_jacobian).argmax(axis=0) + ).ravel() + retry_range = np.arange(rejected_reduced_columns.size) + retry_maximum_difference = np.asarray( + np.abs(retry_jacobian[retry_maximum_rows, retry_range]) + ).ravel() + retry_scale = np.maximum( + np.abs(base_rhs[retry_maximum_rows]), + np.abs( + retry_rhs[ + retry_maximum_rows, + group_map[groups[rejected_reduced_columns]], + ] + ), + ) + use_retry = ( + maximum_difference[rejected_reduced_columns] * retry_scale + < retry_maximum_difference + * difference_scale[rejected_reduced_columns] + ) + if np.any(use_retry): + retry_selection = np.flatnonzero(use_retry) + selected_reduced_columns = rejected_reduced_columns[ + retry_selection + ] + selected_columns = active_columns[selected_reduced_columns] + factor[selected_columns] = increased_factor[retry_selection] + step[selected_columns] = increased_step[retry_selection] + reduced_jacobian[:, selected_reduced_columns] = retry_jacobian[ + :, retry_selection + ] + difference_scale[selected_reduced_columns] = retry_scale[ + retry_selection + ] + maximum_difference[selected_reduced_columns] = ( + retry_maximum_difference[retry_selection] + ) + + reduced_jacobian.data /= np.repeat( + step[active_columns], + np.diff(reduced_jacobian.indptr), + ) + factor[ + active_columns[ + maximum_difference < NUM_JAC_DIFF_SMALL * difference_scale + ] + ] *= NUM_JAC_FACTOR_INCREASE + factor[ + active_columns[ + maximum_difference > NUM_JAC_DIFF_BIG * difference_scale + ] + ] *= NUM_JAC_FACTOR_DECREASE + factor = np.maximum(factor, NUM_JAC_MIN_FACTOR) + + rows, reduced_columns, values = find(reduced_jacobian) + jacobian = csc_matrix( + ( + values, + (rows, active_columns[reduced_columns]), + ), + shape=(self._state_count, self._state_count), + ) + return jacobian, factor + + @staticmethod + def _new_segment_diagnostics(index: int) -> dict[str, int | float]: + return { + "index": index, + "fullBuildCount": 0, + "secantReuseCount": 0, + "auditFailureCount": 0, + "exactColumnBuildCount": 0, + "exactColumnFallbackCount": 0, + "finiteDifferenceRhsEvaluationCount": 0, + "baseRhsEvaluationCount": 0, + "jvAuditRhsEvaluationCount": 0, + "jacobianEvaluationCount": 0, + "secantPairCount": 0, + "assemblySeconds": 0.0, + } + + def _record_segment(self, key: str, value: int | float = 1) -> None: + if not self._segments: + self._segments.append(self._new_segment_diagnostics(0)) + segment = self._segments[-1] + segment[key] = segment[key] + value + + def start_segment(self) -> None: + """Discard numerical state before a breakpoint/event solver restart.""" + + self._jacobian = None + self._original_factor = None + self._remaining_factor = None + self._last_factor = None + self._has_secant_update = False + self._consecutive_reuses = 0 + self._pending_secants.clear() + self._clear_observation() + self._segment_start_count += 1 + if not self._segments: + self._segments.append(self._new_segment_diagnostics(0)) + elif any( + value + for key, value in self._segments[-1].items() + if key != "index" + ): + self._segments.append( + self._new_segment_diagnostics(len(self._segments)) + ) + self._last_decision = "segmentReset" + self._last_audit_relative_error = None + self._last_audit_rms_relative_error = None + self._last_audit_p95_relative_error = None + + def _clear_observation(self) -> None: + self._last_observation_time = None + self._last_observation_state = None + self._last_observation_rhs = None + + def _state_array(self, values: Sequence[float] | np.ndarray) -> np.ndarray: + state = np.asarray(values, dtype=float) + if state.shape != (self._state_count,): + raise ValueError( + f"Expected a state vector of shape {(self._state_count,)}, " + f"received {state.shape}." + ) + if not np.all(np.isfinite(state)): + raise ValueError("Jacobian state vectors must contain finite values.") + return state + + def _rhs_array(self, values: Sequence[float] | np.ndarray) -> np.ndarray: + derivative = np.asarray(values, dtype=float) + if derivative.shape != (self._state_count,): + raise ValueError( + f"Expected an RHS vector of shape {(self._state_count,)}, " + f"received {derivative.shape}." + ) + if not np.all(np.isfinite(derivative)): + raise ValueError("Jacobian RHS evaluations must contain finite values.") + return derivative + + def _evaluate( + self, + time: float, + state: np.ndarray, + *, + purpose: str, + ) -> np.ndarray: + if purpose == "base": + self._base_rhs_count += 1 + self._record_segment("baseRhsEvaluationCount") + elif purpose == "finiteDifference": + self._finite_difference_rhs_count += 1 + self._record_segment("finiteDifferenceRhsEvaluationCount") + elif purpose == "jvAudit": + self._jv_audit_count += 1 + self._record_segment("jvAuditRhsEvaluationCount") + else: # pragma: no cover - internal programming error. + raise RuntimeError(f"Unknown Jacobian evaluation purpose: {purpose}.") + return self._rhs_array(self._evaluate_rhs(float(time), state.copy())) + + def _remember_observation( + self, + time: float, + state: np.ndarray, + derivative: np.ndarray, + ) -> None: + self._last_observation_time = float(time) + self._last_observation_state = state.copy() + self._last_observation_rhs = derivative.copy() + + def observe( + self, + time: float, + state: Sequence[float] | np.ndarray, + derivative: Sequence[float] | np.ndarray, + ) -> None: + """Cache one ordinary RHS sample for a later same-time sparse secant.""" + + if self._constructing or self._max_consecutive_reuses == 0: + return + state_array = self._state_array(state) + derivative_array = self._rhs_array(derivative) + time_value = float(time) + if not math.isfinite(time_value): + raise ValueError("Jacobian observation time must be finite.") + + previous_state = self._last_observation_state + previous_rhs = self._last_observation_rhs + same_time = ( + self._last_observation_time is not None + and time_value == self._last_observation_time + ) + if not same_time: + self._pending_secants.clear() + if ( + self._jacobian is None + or not same_time + or previous_state is None + or previous_rhs is None + ): + self._remember_observation(time_value, state_array, derivative_array) + return + + state_delta = state_array - previous_state + rhs_delta = derivative_array - previous_rhs + state_scale = np.maximum.reduce( + ( + np.abs(state_array), + np.abs(previous_state), + self._atol, + ) + ) + normalized_step = np.max(np.abs(state_delta) / state_scale) + if ( + not math.isfinite(float(normalized_step)) + or normalized_step <= 32.0 * np.finfo(float).eps + ): + self._rejected_observation_count += 1 + self._remember_observation(time_value, state_array, derivative_array) + return + + self._pending_secants.append( + (state_delta.copy(), rhs_delta.copy(), state_scale.copy()) + ) + if len(self._pending_secants) > self._MAX_PENDING_SECANT_PAIRS: + del self._pending_secants[0] + self._record_segment("secantPairCount") + self._remember_observation(time_value, state_array, derivative_array) + + def _apply_pending_secants(self) -> None: + """Apply cached same-time pairs only when a new Jacobian is requested.""" + + if self._jacobian is None or not self._pending_secants: + return + jacobian = self._jacobian.tocsr(copy=True) + updated_row_count = 0 + for state_delta, rhs_delta, state_scale in self._pending_secants: + normalized_step = state_delta / state_scale + for row_index in range(self._state_count): + if row_index in self._exact_row_indexes: + continue + start = int(jacobian.indptr[row_index]) + stop = int(jacobian.indptr[row_index + 1]) + if start == stop: + continue + columns = jacobian.indices[start:stop] + mutable = np.asarray( + [ + column_index not in self._exact_column_index_set + for column_index in columns + ], + dtype=bool, + ) + if not np.any(mutable): + continue + mutable_columns = columns[mutable] + local_normalized_step = normalized_step[mutable_columns] + denominator = float( + np.dot(local_normalized_step, local_normalized_step) + ) + if not math.isfinite(denominator) or denominator <= 0.0: + continue + row_values = jacobian.data[start:stop] + predicted_delta = float( + np.dot(row_values, state_delta[columns]) + ) + correction_scale = ( + float(rhs_delta[row_index]) - predicted_delta + ) / denominator + # Apply Broyden to A = J * diag(state_scale), then transform + # the corrected row back to the physical Jacobian coordinates. + correction = ( + correction_scale + * local_normalized_step + / state_scale[mutable_columns] + ) + if not np.all(np.isfinite(correction)): + continue + row_values[mutable] = row_values[mutable] + correction + updated_row_count += 1 + + self._pending_secants.clear() + + if updated_row_count: + self._jacobian = jacobian + self._has_secant_update = True + self._secant_update_count += 1 + else: + self._rejected_observation_count += 1 + + def _resolve_exact_row( + self, + row_index: int, + time: float, + state: np.ndarray, + ) -> np.ndarray: + specification = self._exact_rows[row_index] + values = ( + specification(time, state.copy()) + if callable(specification) + else specification + ) + row = np.zeros(self._state_count, dtype=float) + if isinstance(values, Mapping): + for column_index, value in values.items(): + if ( + not isinstance(column_index, int) + or isinstance(column_index, bool) + or not 0 <= column_index < self._state_count + ): + raise ValueError( + f"Exact Jacobian column index {column_index!r} is out of range." + ) + row[column_index] = float(value) + else: + dense_values = np.asarray(values, dtype=float) + if dense_values.shape != (self._state_count,): + raise ValueError( + f"Exact Jacobian row {row_index} must have shape " + f"{(self._state_count,)}, received {dense_values.shape}." + ) + row[:] = dense_values + if not np.all(np.isfinite(row)): + raise ValueError("Exact Jacobian rows must contain finite values.") + return row + + def _with_exact_rows(self, jacobian, time: float, state: np.ndarray): + if not self._exact_rows: + return jacobian.tocsr(copy=True) + result = jacobian.tolil(copy=True) + for row_index in sorted(self._exact_rows): + row = self._resolve_exact_row(row_index, time, state) + columns = np.flatnonzero(row) + result.rows[row_index] = [int(column) for column in columns] + result.data[row_index] = [float(row[column]) for column in columns] + return result.tocsr() + + def _resolve_exact_columns( + self, + time: float, + state: np.ndarray, + ) -> np.ndarray: + column_count = len(self._exact_column_indexes) + if column_count == 0: + self._exact_column_outside_pattern_nonzero_count = 0 + return np.empty((self._state_count, 0), dtype=float) + + if self._fixed_exact_column_values is not None: + columns = self._fixed_exact_column_values.copy() + else: + assert self._exact_column_provider is not None + values = self._exact_column_provider( + float(time), + state.copy(), + self._exact_column_indexes, + ) + if isinstance(values, Mapping): + returned_indexes = tuple(values) + if returned_indexes != self._exact_column_indexes: + raise ValueError( + "Exact Jacobian column provider mapping keys must match " + "the requested normalized column order." + ) + provider_columns: list[np.ndarray] = [] + for column_index in returned_indexes: + column = np.asarray(values[column_index], dtype=float) + if column.shape != (self._state_count,): + raise ValueError( + f"Exact Jacobian column {column_index} must have shape " + f"{(self._state_count,)}, received {column.shape}." + ) + provider_columns.append(column) + columns = np.column_stack(provider_columns) + else: + columns = np.asarray(values, dtype=float) + + expected_shape = (self._state_count, column_count) + if columns.shape != expected_shape: + raise ValueError( + "Exact Jacobian column provider must return shape " + f"{expected_shape}, received {columns.shape}." + ) + if not np.all(np.isfinite(columns)): + raise ValueError("Exact Jacobian columns must contain finite values.") + self._exact_column_outside_pattern_nonzero_count = int( + np.count_nonzero((columns != 0.0) & ~self._exact_column_pattern) + ) + return columns + + def _evaluate_exact_column_base_rhs( + self, + time: float, + state: np.ndarray, + ) -> np.ndarray: + """Evaluate a base RHS while allowing a provider one-shot capture.""" + + request_capture = getattr( + self._exact_column_provider, + "request_primal_capture", + None, + ) + cancel_capture = getattr( + self._exact_column_provider, + "cancel_primal_capture", + None, + ) + try: + if request_capture is not None: + request_capture() + return self._evaluate(time, state, purpose="base") + finally: + if cancel_capture is not None: + cancel_capture() + + def _with_exact_columns( + self, + jacobian, + time: float, + state: np.ndarray, + *, + resolved_columns: np.ndarray | None = None, + ): + if not self._exact_column_indexes: + self._exact_column_outside_pattern_nonzero_count = 0 + return jacobian.tocsr(copy=True) + + from scipy.sparse import csc_matrix, diags + + columns = ( + self._resolve_exact_columns(time, state) + if resolved_columns is None + else resolved_columns + ) + keep_column = np.ones(self._state_count, dtype=float) + keep_column[list(self._exact_column_indexes)] = 0.0 + remaining = jacobian.tocsc(copy=True) @ diags( + keep_column, + format="csc", + ) + row_indexes = np.tile( + np.arange(self._state_count, dtype=int), + len(self._exact_column_indexes), + ) + column_indexes = np.repeat( + np.asarray(self._exact_column_indexes, dtype=int), + self._state_count, + ) + exact = csc_matrix( + ( + columns.ravel(order="F"), + (row_indexes, column_indexes), + ), + shape=(self._state_count, self._state_count), + ) + exact.eliminate_zeros() + return (remaining + exact).tocsr() + + def _with_exact_overrides( + self, + jacobian, + time: float, + state: np.ndarray, + *, + resolved_columns: np.ndarray | None = None, + ): + # Full columns are assembled first. Full rows intentionally win at + # row/column intersections, matching the documented precedence. + with_columns = self._with_exact_columns( + jacobian, + time, + state, + resolved_columns=resolved_columns, + ) + return self._with_exact_rows(with_columns, time, state) + + def _record_exact_column_fallback( + self, + unavailable: ExactColumnsUnavailable, + ) -> None: + self._exact_column_fallback_count += 1 + self._last_exact_column_fallback_reason = unavailable.reason + self._record_segment("exactColumnFallbackCount") + + def _full_build( + self, + time: float, + state: np.ndarray, + *, + decision: str = "fullBuild", + base_rhs: np.ndarray | None = None, + exact_column_unavailable: ExactColumnsUnavailable | None = None, + ): + from scipy.sparse import csr_matrix + + finite_difference_rhs_before = self._finite_difference_rhs_count + self._constructing = True + try: + resolved_exact_columns = None + unavailable = exact_column_unavailable + if self._exact_column_indexes and unavailable is None: + # Tangent providers operate on the closure left by a real + # primal RHS at exactly (time, state). Resolve them before any + # grouped perturbation dirties the shared model. + if base_rhs is None: + base_rhs = self._evaluate_exact_column_base_rhs( + time, + state, + ) + try: + resolved_exact_columns = self._resolve_exact_columns( + time, + state, + ) + except ExactColumnsUnavailable as exc: + unavailable = exc + + use_exact_columns = bool( + self._exact_column_indexes and unavailable is None + ) + if unavailable is not None: + self._record_exact_column_fallback(unavailable) + self._exact_column_outside_pattern_nonzero_count = 0 + if decision == "fullBuild": + decision = "exactColumnFallback" + + finite_difference_sparsity = ( + self._remaining_finite_difference_sparsity + if use_exact_columns + else self._original_finite_difference_sparsity + ) + if finite_difference_sparsity.nnz == 0: + selected_factor = ( + self._remaining_factor + if use_exact_columns + else self._original_factor + ) + self._last_factor = ( + None if selected_factor is None else selected_factor.copy() + ) + jacobian = csr_matrix( + (self._state_count, self._state_count), dtype=float + ) + else: + if base_rhs is None: + base_rhs = self._evaluate(time, state, purpose="base") + + def vectorized_evaluate( + evaluation_time: float, + states: np.ndarray, + ) -> np.ndarray: + states_array = np.asarray(states, dtype=float) + if states_array.ndim == 1: + return self._evaluate( + evaluation_time, + states_array.copy(), + purpose="finiteDifference", + ) + if ( + states_array.ndim != 2 + or states_array.shape[0] != self._state_count + ): + raise ValueError( + "SciPy requested an unexpected vectorized Jacobian shape." + ) + return np.column_stack( + [ + self._evaluate( + evaluation_time, + states_array[:, column_index].copy(), + purpose="finiteDifference", + ) + for column_index in range(states_array.shape[1]) + ] + ) + + if use_exact_columns: + jacobian, self._remaining_factor = self._subset_sparse_num_jac( + vectorized_evaluate, + float(time), + state, + base_rhs, + ) + self._last_factor = self._remaining_factor.copy() + else: + jacobian, self._original_factor = self._num_jac( + fun=vectorized_evaluate, + t=float(time), + y=state, + f=base_rhs, + threshold=self._atol, + factor=self._original_factor, + sparsity=( + self._original_finite_difference_sparsity, + self._original_groups, + ), + ) + self._last_factor = self._original_factor.copy() + jacobian = csr_matrix(jacobian, dtype=float) + if not np.all(np.isfinite(jacobian.data)): + raise ValueError( + "Numerical Jacobian construction returned non-finite values." + ) + if use_exact_columns: + jacobian = self._with_exact_overrides( + jacobian, + time, + state, + resolved_columns=resolved_exact_columns, + ) + else: + # This is exactly the no-provider seed-0 numerical path. Full + # rows may still overwrite their numerical values, but no + # reduced column pattern leaks into the fallback assembly. + jacobian = self._with_exact_rows(jacobian, time, state) + finally: + self._constructing = False + + self._jacobian = jacobian + if use_exact_columns: + self._exact_column_build_count += 1 + self._record_segment("exactColumnBuildCount") + self._last_build_finite_difference_rhs_count = ( + self._finite_difference_rhs_count - finite_difference_rhs_before + ) + self._full_build_count += 1 + self._record_segment("fullBuildCount") + self._has_secant_update = False + self._consecutive_reuses = 0 + self._pending_secants.clear() + self._last_decision = decision + if decision != "auditFallback": + self._last_audit_relative_error = None + self._last_audit_rms_relative_error = None + self._last_audit_p95_relative_error = None + return jacobian.copy() + + def _audit_step(self, state: np.ndarray) -> np.ndarray: + epsilon_root = math.sqrt(np.finfo(float).eps) + factor = ( + np.asarray(self._last_factor, dtype=float) + if self._last_factor is not None + else None + ) + if factor is None or factor.shape != (self._state_count,): + factor = np.full(self._state_count, epsilon_root, dtype=float) + factor = np.where( + np.isfinite(factor) & (factor > 0.0), + factor, + epsilon_root, + ) + magnitude = factor * np.maximum(np.abs(state), self._atol) + indexes = np.arange(self._state_count) + signs = np.where(indexes % 2 == 0, 1.0, -1.0) + # Do not cross zero for tiny positive masses/energies or other bounded + # coordinates. Away from zero, alternating signs reduce cancellation. + close_to_zero = np.abs(state) <= 4.0 * magnitude + signs = np.where( + close_to_zero, + np.where(state < 0.0, -1.0, 1.0), + signs, + ) + requested_step = signs * magnitude + return (state + requested_step) - state + + def _audit_candidate( + self, + time: float, + state: np.ndarray, + jacobian, + *, + base_rhs: np.ndarray | None = None, + ) -> tuple[bool, np.ndarray | None]: + try: + if base_rhs is None: + base_rhs = self._evaluate(time, state, purpose="base") + step = self._audit_step(state) + perturbed_rhs = self._evaluate( + time, + state + step, + purpose="jvAudit", + ) + except RecoverableTrialStateError: + self._last_audit_relative_error = math.inf + self._last_audit_rms_relative_error = math.inf + self._last_audit_p95_relative_error = math.inf + return False, base_rhs + + observed_delta = perturbed_rhs - base_rhs + predicted_delta = np.asarray(jacobian @ step, dtype=float).reshape(-1) + audited_rows = np.ones(self._state_count, dtype=bool) + if self._exact_row_indexes: + audited_rows[list(self._exact_row_indexes)] = False + if not np.any(audited_rows): + self._last_audit_relative_error = 0.0 + self._last_audit_rms_relative_error = 0.0 + self._last_audit_p95_relative_error = 0.0 + return True, base_rhs + + observed = observed_delta[audited_rows] + predicted = predicted_delta[audited_rows] + error = np.abs(observed - predicted) + delta_scale = np.maximum(np.abs(observed), np.abs(predicted)) + rhs_scale = np.maximum( + np.abs(base_rhs[audited_rows]), + np.abs(perturbed_rhs[audited_rows]), + ) + roundoff_allowance = 64.0 * np.finfo(float).eps * np.maximum( + rhs_scale, + 1.0, + ) + tolerance = ( + self._audit_absolute_tolerance + + roundoff_allowance + + self._audit_relative_tolerance * delta_scale + ) + informative = delta_scale > ( + roundoff_allowance + self._audit_absolute_tolerance + ) + # A direction whose observed and predicted deltas are both buried in + # closure/floating-point noise did not validate the matrix. Treat it + # as inconclusive and refresh instead of accepting a vacuous 0 == 0. + passed = bool(np.any(informative) and np.all(error <= tolerance)) + relative_denominator = np.maximum( + delta_scale, + roundoff_allowance + self._audit_absolute_tolerance, + ) + relative_error = error / relative_denominator + self._last_audit_relative_error = float( + np.max(relative_error, initial=0.0) + ) + self._last_audit_rms_relative_error = float( + np.sqrt(np.mean(relative_error * relative_error)) + ) + self._last_audit_p95_relative_error = float( + np.percentile(relative_error, 95.0) + ) + return passed, base_rhs + + def _evaluate_jacobian( + self, + time_value: float, + state_array: np.ndarray, + ): + if self._jacobian is None: + return self._full_build(time_value, state_array) + + if self._consecutive_reuses >= self._max_consecutive_reuses: + return self._full_build(time_value, state_array) + + self._apply_pending_secants() + if not self._has_secant_update: + return self._full_build(time_value, state_array) + + exact_base_rhs = None + resolved_exact_columns = None + if self._exact_column_indexes: + exact_base_rhs = self._evaluate_exact_column_base_rhs( + time_value, + state_array, + ) + try: + resolved_exact_columns = self._resolve_exact_columns( + time_value, + state_array, + ) + except ExactColumnsUnavailable as exc: + return self._full_build( + time_value, + state_array, + decision="exactColumnFallback", + base_rhs=exact_base_rhs, + exact_column_unavailable=exc, + ) + candidate = self._with_exact_overrides( + self._jacobian, + time_value, + state_array, + resolved_columns=resolved_exact_columns, + ) + audit_passed, _base_rhs = self._audit_candidate( + time_value, + state_array, + candidate, + base_rhs=exact_base_rhs, + ) + if not audit_passed: + self._audit_failure_count += 1 + self._record_segment("auditFailureCount") + return self._full_build( + time_value, + state_array, + decision="auditFallback", + ) + + self._jacobian = candidate + self._secant_reuse_count += 1 + self._record_segment("secantReuseCount") + self._consecutive_reuses += 1 + self._has_secant_update = False + self._last_decision = "secantReuse" + return candidate.copy() + + def __call__( + self, + time: float, + state: Sequence[float] | np.ndarray, + ): + """Return a CSR Jacobian suitable for SciPy BDF/Radau.""" + + time_value = float(time) + if not math.isfinite(time_value): + raise ValueError("Jacobian evaluation time must be finite.") + state_array = self._state_array(state) + started_at = perf_counter() + self._jacobian_evaluation_count += 1 + self._record_segment("jacobianEvaluationCount") + try: + return self._evaluate_jacobian(time_value, state_array) + finally: + elapsed = perf_counter() - started_at + self._assembly_seconds += elapsed + self._record_segment("assemblySeconds", elapsed) + + def diagnostics(self) -> dict[str, object]: + """Return cumulative work counters and the latest reuse decision.""" + + return { + "mode": ( + "hybridSparseSecant" + if self._max_consecutive_reuses + else "optimizedSparseFiniteDifference" + ), + "fullBuildCount": self._full_build_count, + "secantReuseCount": self._secant_reuse_count, + "auditFailureCount": self._audit_failure_count, + "finiteDifferenceRhsEvaluationCount": ( + self._finite_difference_rhs_count + ), + "baseRhsEvaluationCount": self._base_rhs_count, + "jvAuditEvaluationCount": self._jv_audit_count, + "secantUpdateCount": self._secant_update_count, + "rejectedObservationCount": self._rejected_observation_count, + "segmentStartCount": self._segment_start_count, + "jacobianEvaluationCount": self._jacobian_evaluation_count, + "assemblySeconds": self._assembly_seconds, + "colorGroupCount": self._color_group_count, + "defaultColorGroupCount": self._default_color_group_count, + "originalColorGroupCount": self._original_color_group_count, + "remainingColorGroupCount": self._remaining_color_group_count, + "coloringSeed": self._coloring_seed, + "exactRowCount": len(self._exact_rows), + "exactColumnCount": len(self._exact_column_indexes), + "finiteDifferenceColumnCount": self._finite_difference_column_count, + "exactColumnOutsidePatternNonzeroCount": ( + self._exact_column_outside_pattern_nonzero_count + ), + "exactColumnBuildCount": self._exact_column_build_count, + "exactColumnFallbackCount": self._exact_column_fallback_count, + "lastExactColumnFallbackReason": ( + self._last_exact_column_fallback_reason + ), + "lastBuildFiniteDifferenceRhsEvaluationCount": ( + self._last_build_finite_difference_rhs_count + ), + "consecutiveReuseCount": self._consecutive_reuses, + "lastDecision": self._last_decision, + "lastAuditRelativeError": self._last_audit_relative_error, + "lastAuditRmsRelativeError": self._last_audit_rms_relative_error, + "lastAuditP95RelativeError": self._last_audit_p95_relative_error, + "segments": [dict(segment) for segment in self._segments], + } diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py index e5440ec..711e7ca 100644 --- a/app/simulation/solvers/solver.py +++ b/app/simulation/solvers/solver.py @@ -12,6 +12,7 @@ CancellationCheck = Callable[[], bool] AcceptedStepCallback = Callable[[float], None] IntegrationStatus = Literal["completed", "cancelled", "failed"] DenseState = Callable[[float], list[float]] +JacobianCallable = Callable[[float, object], object] @dataclass(frozen=True) @@ -30,7 +31,9 @@ StateTransitionHandler = Callable[ _MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64 -class _IntegrationCancelled(Exception): +class IntegrationCancelled(Exception): + """Internal control-flow signal shared by RHS and Jacobian evaluation.""" + pass @@ -59,9 +62,19 @@ class SolverSegmentDiagnostics: solver_start_count: int = 0 state_transition_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]: - return { + result: dict[str, float | int] = { "startTime": self.start_time, "requestedStopTime": self.requested_stop_time, "simulatedUntil": self.simulated_until, @@ -73,6 +86,61 @@ class SolverSegmentDiagnostics: "stateTransitionCount": self.state_transition_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) @@ -309,7 +377,7 @@ def _runge_kutta_4( for target_time in t_eval[1:]: while current_time < target_time: if cancel_check is not None and cancel_check(): - raise _IntegrationCancelled + raise IntegrationCancelled dt = min(config.max_step, target_time - current_time) k1 = rhs(current_time, state) 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) _append_solution_sample(times, states, target_time, state) - except _IntegrationCancelled: + except IntegrationCancelled: status = "cancelled" message = "Simulation was stopped before reaching the requested end time." _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 while current_time < target_time: if cancel_check is not None and cancel_check(): - raise _IntegrationCancelled + raise IntegrationCancelled dt = min(config.max_step, target_time - current_time) k1 = rhs(current_time, state) k2 = rhs( @@ -565,7 +633,7 @@ def _runge_kutta_4_segmented( sample_time = float(sample_times[sample_index]) _append_solution_sample(times, states, sample_time, state) sample_index += 1 - except _IntegrationCancelled: + except IntegrationCancelled: status = "cancelled" message = "Simulation was stopped before reaching the requested end time." _append_solution_sample(times, states, current_time, state) @@ -595,6 +663,7 @@ def _integrate_scipy_stepwise( breakpoints: Sequence[float] = (), state_transition_handler: StateTransitionHandler | None = None, jac_sparsity=None, + jac: JacobianCallable | None = None, ) -> ODESolution: """Initial stepwise integration path for breakpoints and state resets. @@ -617,6 +686,7 @@ def _integrate_scipy_stepwise( solver_type = solver_types.get(config.method) if solver_type is None: raise ValueError(f"Unsupported integration method: {config.method}") + implicit_jac = jac if config.method in {"BDF", "Radau"} else None times = [float(config.t_start)] states = [[float(value)] for value in initial_state] @@ -632,8 +702,13 @@ def _integrate_scipy_stepwise( def cancellable_rhs(time, state): if cancel_check(): - raise _IntegrationCancelled - return rhs(float(time), [float(value) for value in state]) + raise IntegrationCancelled + 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" message = "The solver successfully reached the end of the integration interval." @@ -683,6 +758,7 @@ def _integrate_scipy_stepwise( segment_solver_starts = 0 segment_state_transitions = 0 segment_recoverable_retries = 0 + jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac) while has_integration_interval and last_accepted_time < integration_end: if cancel_check(): @@ -695,8 +771,11 @@ def _integrate_scipy_stepwise( "atol": config.atol, "max_step": segment_max_step, } - if jac_sparsity is not None and config.method in {"BDF", "Radau"}: - solver_options["jac_sparsity"] = jac_sparsity + if config.method in {"BDF", "Radau"}: + 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 = ( 0.1 * segment_max_step if last_recoverable_error is not None @@ -709,6 +788,9 @@ def _integrate_scipy_stepwise( ) try: + start_segment = getattr(implicit_jac, "start_segment", None) + if start_segment is not None: + start_segment() solver = solver_type( cancellable_rhs, last_accepted_time, @@ -716,7 +798,7 @@ def _integrate_scipy_stepwise( integration_end, **solver_options, ) - except _IntegrationCancelled: + except IntegrationCancelled: status = "cancelled" message = cancellation_message() break @@ -755,7 +837,7 @@ def _integrate_scipy_stepwise( step_start_state = list(last_accepted_state) try: step_message = solver.step() - except _IntegrationCancelled: + except IntegrationCancelled: status = "cancelled" message = ( "Simulation was stopped before reaching the requested end time." @@ -957,6 +1039,11 @@ def _integrate_scipy_stepwise( if not restart_at_transition: 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( SolverSegmentDiagnostics( start_time=float(segment_start_time), @@ -971,6 +1058,34 @@ def _integrate_scipy_stepwise( solver_start_count=segment_solver_starts, state_transition_count=segment_state_transitions, 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": @@ -1034,6 +1149,7 @@ def integrate_ode( breakpoints: Sequence[float] | None = None, state_transition_handler: StateTransitionHandler | None = None, jac_sparsity=None, + jac: JacobianCallable | None = None, ): """Integrate an ODE, optionally restarting at equation discontinuities. @@ -1104,10 +1220,26 @@ def integrate_ode( normalized_breakpoints, state_transition_handler, 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 = { - "fun": rhs, + "fun": solve_rhs, "t_span": (config.t_start, config.t_stop), "y0": initial_state, "method": config.method, @@ -1118,6 +1250,8 @@ def integrate_ode( } if config.first_step is not None: 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 return solve_ivp(**solve_options) diff --git a/app/simulation/solvers/tangent.py b/app/simulation/solvers/tangent.py new file mode 100644 index 0000000..fea4799 --- /dev/null +++ b/app/simulation/solvers/tangent.py @@ -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), + ) diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py index 3a92ff4..876cb59 100644 --- a/app/simulation/systems/generic.py +++ b/app/simulation/systems/generic.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, replace from math import floor, isfinite +import os from typing import Literal 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.solvers.algebraic import PressureFlowSolver from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver +from app.simulation.solvers.jacobian import ( + SparseJacobianCompatibilityError, + SparseSecantJacobian, +) from app.simulation.solvers.mechanical import ( MechanicalConstraintGroup, MechanicalStateReducer, @@ -21,15 +26,58 @@ from app.simulation.solvers.pneumatic_storage import ( ideal_storage_group_is_reducible, ) 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.stream import StreamResolver +from app.simulation.solvers.tangent import ( + ThreePistonTangentCompilation, + ThreePistonTangentProvider, + compile_three_piston_tangent_provider, +) from app.simulation.systems.network import Endpoint, SimulationNetwork SimulationProgressCallback = Callable[[float, str], None] SimulationCancellationCheck = Callable[[], bool] 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) @@ -398,6 +446,7 @@ class GenericFluidSystem: self.signal_propagation_count = 0 self.pneumatic_volume_propagation_count = 0 self._jacobian_sparsity = None + self._ode_tangent_provider: ThreePistonTangentProvider | None = None def _request_causal_residual_audit(self) -> None: """Make topology or mode boundaries verify the next causal closure.""" @@ -874,6 +923,23 @@ class GenericFluidSystem: "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( "simulation.closure", minimum_mode="audit", @@ -1065,7 +1131,11 @@ class GenericFluidSystem: def rhs(self, _time: float, state_vector: list[float]) -> list[float]: self.apply_state_vector(state_vector) 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: for component in self.network.components.values(): @@ -1156,29 +1226,112 @@ class GenericFluidSystem: report_solver_time(time) 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): transition = self.mechanical_state_reducer.state_transition(*args) if transition is not None: self._request_causal_residual_audit() return transition - solution = integrate_ode( - rhs=monitored_rhs, - initial_state=initial_state, - config=integration_config, - t_eval=t_eval, - cancel_check=cancel_check, - accepted_step_callback=( - report_solver_time if cancel_check is not None else None - ), - breakpoints=signal_event_times, - state_transition_handler=( - handle_state_transition - if self.mechanical_state_reducer.has_state_events - else None - ), - jac_sparsity=jac_sparsity, - ) + try: + solution = integrate_ode( + rhs=monitored_rhs, + initial_state=initial_state, + config=integration_config, + t_eval=t_eval, + cancel_check=cancel_check, + accepted_step_callback=( + report_solver_time if cancel_check is not None else None + ), + breakpoints=signal_event_times, + state_transition_handler=( + handle_state_transition + if self.mechanical_state_reducer.has_state_events + else None + ), + jac_sparsity=jac_sparsity, + jac=jacobian, + ) + finally: + self._ode_tangent_provider = None if isinstance(solution, ODESolution): run_status: SimulationRunStatus = solution.status integration_error = solution.error @@ -1212,6 +1365,44 @@ class GenericFluidSystem: "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 = ( "nfev", "njev", @@ -1225,21 +1416,123 @@ class GenericFluidSystem: key: sum(int(segment[key]) for segment in solver_segment_diagnostics) 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 = ( self.jacobian_sparsity_diagnostics() if integration_config.method in {"BDF", "Radau"} else None ) + runtime_jacobian_diagnostics: dict[str, object] | None = None if jacobian_diagnostics is not None: color_group_count = int(jacobian_diagnostics["colorGroupCount"]) - for segment in solver_segment_diagnostics: - segment["finiteDifferenceRhsEstimate"] = ( - int(segment["njev"]) * color_group_count + if jacobian is None: + for segment in solver_segment_diagnostics: + 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( int(segment["finiteDifferenceRhsEstimate"]) 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"): series: dict[str, list[float]] = {"time": []} @@ -1286,6 +1579,7 @@ class GenericFluidSystem: "integration": { "method": integration_config.method, "jacobianSparsity": jacobian_diagnostics, + "jacobian": runtime_jacobian_diagnostics, "segmentCount": len(solver_segment_diagnostics), "segments": solver_segment_diagnostics, "totals": solver_totals, diff --git a/bat/start-all.bat b/bat/start-all.bat new file mode 100644 index 0000000..68d59e8 --- /dev/null +++ b/bat/start-all.bat @@ -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 diff --git a/bat/start-all.sh b/bat/start-all.sh new file mode 100755 index 0000000..80a80f1 --- /dev/null +++ b/bat/start-all.sh @@ -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" diff --git a/start-backend.bat b/bat/start-backend.bat similarity index 50% rename from start-backend.bat rename to bat/start-backend.bat index ef6b363..42fea6a 100644 --- a/start-backend.bat +++ b/bat/start-backend.bat @@ -1,15 +1,28 @@ @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 -set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe" +set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe" if not exist "%PYTHON_EXE%" ( echo [ERROR] Python virtual environment was not found: echo %PYTHON_EXE% 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 exit /b 1 ) @@ -27,4 +40,5 @@ if not "%EXIT_CODE%"=="0" ( pause ) +popd exit /b %EXIT_CODE% diff --git a/bat/start-backend.sh b/bat/start-backend.sh new file mode 100755 index 0000000..08511d8 --- /dev/null +++ b/bat/start-backend.sh @@ -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 diff --git a/bat/start-reactflow.bat b/bat/start-reactflow.bat new file mode 100644 index 0000000..5654496 --- /dev/null +++ b/bat/start-reactflow.bat @@ -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% diff --git a/bat/start-reactflow.sh b/bat/start-reactflow.sh new file mode 100755 index 0000000..16020e3 --- /dev/null +++ b/bat/start-reactflow.sh @@ -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 diff --git a/docs/README.md b/docs/README.md index 0da9383..6ef2807 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 只保存求解所需的 -组件实例、模型版本、参数、连接和仿真设置。端口契约由注册模型恢复,画布位置、图标 -方向等编辑信息只属于工程 JSON。XML 解析器不能自行创造模型端口或参数。 +## HH:mm -## 当前代码入口 - -| 目的 | 文件 | -| --- | --- | -| 组件基类 | [`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` 受控登记公开模型。 -- 不在前端复制后端端口、参数或默认值作为正式来源。 -- 不覆盖用户已有改动。 -- 不自行猜测缺失的物理方程。 -- 修改后运行针对性测试和完整回归,并报告未完成的验证。 +- 完成的修改、结果及影响范围。 +``` diff --git a/docs/amesim-component-migration-matrix.md b/docs/other/amesim-component-migration-matrix.md similarity index 98% rename from docs/amesim-component-migration-matrix.md rename to docs/other/amesim-component-migration-matrix.md index f2f2171..440211b 100644 --- a/docs/amesim-component-migration-matrix.md +++ b/docs/other/amesim-component-migration-matrix.md @@ -4,7 +4,7 @@ 适用模型:`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) ## 目标 diff --git a/docs/amesim-helium-peng-robinson.md b/docs/other/amesim-helium-peng-robinson.md similarity index 100% rename from docs/amesim-helium-peng-robinson.md rename to docs/other/amesim-helium-peng-robinson.md diff --git a/docs/仿真性能评估-2026-08-15.md b/docs/other/仿真性能评估-2026-08-15.md similarity index 100% rename from docs/仿真性能评估-2026-08-15.md rename to docs/other/仿真性能评估-2026-08-15.md diff --git a/docs/后端求解逻辑与效率优化调研.md b/docs/other/后端求解逻辑与效率优化调研.md similarity index 99% rename from docs/后端求解逻辑与效率优化调研.md rename to docs/other/后端求解逻辑与效率优化调研.md index 33d4575..d1999dc 100644 --- a/docs/后端求解逻辑与效率优化调研.md +++ b/docs/other/后端求解逻辑与效率优化调研.md @@ -406,8 +406,8 @@ STEP0、UD00 等信号源提供离散事件时刻。积分器先推进到事件 ### 10.1 进程与线程 -- `start-all.bat` 分别启动 Vite 与 FastAPI(`start-all.bat:19-22`)。 -- `start-backend.bat` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker(`start-backend.bat:17-21`)。 +- `bat/start-all.bat` 与 `bat/start-all.sh` 分别启动 Vite 与 FastAPI。 +- `bat/start-backend.bat` 与 `bat/start-backend.sh` 的 Uvicorn 命令没有 `--workers`,当前脚本即单进程单 worker。 - 每个流式仿真创建一个 daemon `threading.Thread` 和一个无界 `queue.Queue`;没有信号量、线程池或排队上限(`app/main.py:773-880`)。 - 全局任务字典只在读写元数据时持锁,不限制同时启动的求解数量。 - `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/benchmark_performance.py` | `python -m app.simulation.benchmark_performance` | | 前端流式协议 | `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` | 通用仿真、事件、取消 | diff --git a/docs/接口类型与表示方式总结.md b/docs/other/接口类型与表示方式总结.md similarity index 98% rename from docs/接口类型与表示方式总结.md rename to docs/other/接口类型与表示方式总结.md index 225daac..bfecbfd 100644 --- a/docs/接口类型与表示方式总结.md +++ b/docs/other/接口类型与表示方式总结.md @@ -79,7 +79,7 @@ | 4 | 网络连接层 | `app/simulation/systems/network.py:83-150`:`SimulationNetwork.connect()` | 真正接线时做最终兼容检查 | | 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. 常见术语翻译表 @@ -337,7 +337,7 @@ amesim_step0.out ──> amesim_forc.res [力源] amesim_forc.port_2 ── 机 ### 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 是什么结构 @@ -554,7 +554,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解 ### 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. 当前模型覆盖范围 @@ -629,7 +629,7 @@ XML 语义检查会把未连接端口记为 warning;真正进入通用求解 | 工程 JSON 与编译 | `app/main.py:86-155, 1181-1344` | `ReactFlowPortDefinition`、`compile_reactflow_network()` | | JSON 转 XML | `app/main.py:947-1097` | `build_reactflow_system_xml()` | | 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` | | 前端端口和工程类型 | `frontend/src/App.tsx` | `PortDefinition`、`ReactFlowProjectPayload` | | 前端生成 XML | `frontend/src/App.tsx` | `buildSystemXml()`、`projectConnectionMetadata()` | diff --git a/docs/other/求解器性能优化任务清单.md b/docs/other/求解器性能优化任务清单.md new file mode 100644 index 0000000..cb22609 --- /dev/null +++ b/docs/other/求解器性能优化任务清单.md @@ -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 / + +- 状态:未开始 → 进行中 / 部分实现 → 已完成 +- 实现范围: +- 未覆盖范围: +- 运行环境: +- 输入与配置: +- 正确性结果: +- 性能结果(中位数与离散度): +- 回退/审计结果: +- 风险或已知退化: +- 决策:合入默认路径 / 继续实验 / 回滚 / 不采用 +- 证据文件或 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) diff --git a/docs/backend-interface-version-spec-v1.md b/docs/standard/backend-interface-version-spec-v1.md similarity index 99% rename from docs/backend-interface-version-spec-v1.md rename to docs/standard/backend-interface-version-spec-v1.md index 846b038..a0b2569 100644 --- a/docs/backend-interface-version-spec-v1.md +++ b/docs/standard/backend-interface-version-spec-v1.md @@ -111,7 +111,7 @@ System XML v3 是当前唯一支持的 XML 求解输入。根元素固定使用 `modelVersion` 必须与当前注册模型完全一致。版本不一致时返回 `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 diff --git a/docs/component-library-spec-v1.md b/docs/standard/component-library-spec-v1.md similarity index 99% rename from docs/component-library-spec-v1.md rename to docs/standard/component-library-spec-v1.md index 54a948d..385287f 100644 --- a/docs/component-library-spec-v1.md +++ b/docs/standard/component-library-spec-v1.md @@ -247,7 +247,7 @@ class ExampleComponent(Component): 7. 模型的方程不能依赖图标方向、界面分类或画布位置。 完整方程示例参见 -[`app/simulation/components/example.md`](../app/simulation/components/example.md)。 +[`app/simulation/components/example.md`](../../app/simulation/components/example.md)。 ## 7. 界面显示声明 diff --git a/docs/component-model-authoring-spec-v1.md b/docs/standard/component-model-authoring-spec-v1.md similarity index 97% rename from docs/component-model-authoring-spec-v1.md rename to docs/standard/component-model-authoring-spec-v1.md index bef0257..07cd4f9 100644 --- a/docs/component-model-authoring-spec-v1.md +++ b/docs/standard/component-model-authoring-spec-v1.md @@ -47,7 +47,7 @@ 应放在对应 `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 新增物理域 @@ -70,11 +70,11 @@ 1. 本文档。 2. 目标库的 `library.py`。 3. 同分类中物理行为最接近的现有模型。 -4. [`core/base.py`](../app/simulation/core/base.py)。 -5. [`core/ports.py`](../app/simulation/core/ports.py)。 -6. [`core/metadata.py`](../app/simulation/core/metadata.py)。 -7. [`core/catalog.py`](../app/simulation/core/catalog.py)。 -8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。 +4. [`core/base.py`](../../app/simulation/core/base.py)。 +5. [`core/ports.py`](../../app/simulation/core/ports.py)。 +6. [`core/metadata.py`](../../app/simulation/core/metadata.py)。 +7. [`core/catalog.py`](../../app/simulation/core/catalog.py)。 +8. [`registry.py`](../../app/simulation/registry.py) 中的启动校验。 9. 与目标模型最接近的测试。 不要只根据文件名、前端图标或旧 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. 注册模型 diff --git a/docs/system-xml-v3.md b/docs/standard/system-xml-v3.md similarity index 98% rename from docs/system-xml-v3.md rename to docs/standard/system-xml-v3.md index 2750e78..0dff855 100644 --- a/docs/system-xml-v3.md +++ b/docs/standard/system-xml-v3.md @@ -2,7 +2,7 @@ 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. 设计边界 diff --git a/docs/更新日志-2026-08-15.md b/docs/update-log/更新日志-2026-08-15.md similarity index 100% rename from docs/更新日志-2026-08-15.md rename to docs/update-log/更新日志-2026-08-15.md diff --git a/docs/update-log/更新日志-2026-08-17.md b/docs/update-log/更新日志-2026-08-17.md new file mode 100644 index 0000000..5f42cb3 --- /dev/null +++ b/docs/update-log/更新日志-2026-08-17.md @@ -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 路径。测试资源移动属于用户操作,本日志未将其计入其他会话的完成成果。 diff --git a/frontend/start-dev.bat b/frontend/start-dev.bat deleted file mode 100644 index ed3045f..0000000 --- a/frontend/start-dev.bat +++ /dev/null @@ -1 +0,0 @@ -powershell -ExecutionPolicy Bypass -File "%~dp0start-dev.ps1" \ No newline at end of file diff --git a/frontend/start-dev.ps1 b/frontend/start-dev.ps1 deleted file mode 100644 index 5f5279c..0000000 --- a/frontend/start-dev.ps1 +++ /dev/null @@ -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 diff --git a/start-all.bat b/start-all.bat deleted file mode 100644 index 52fb729..0000000 --- a/start-all.bat +++ /dev/null @@ -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 diff --git a/start-reactflow.bat b/start-reactflow.bat deleted file mode 100644 index 3a35abd..0000000 --- a/start-reactflow.bat +++ /dev/null @@ -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% diff --git a/tests/fixtures/high_stiffness_explicit_rk45.xml b/tests/data/fixtures/high_stiffness_explicit_rk45.xml similarity index 100% rename from tests/fixtures/high_stiffness_explicit_rk45.xml rename to tests/data/fixtures/high_stiffness_explicit_rk45.xml diff --git a/tests/data/test_mql-full-branches-01-04.xml b/tests/data/test_mql-full-branches-01-04.xml new file mode 100644 index 0000000..85ebdcf --- /dev/null +++ b/tests/data/test_mql-full-branches-01-04.xml @@ -0,0 +1,1218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt b/tests/data/testmodel/testmodel_modelica_comparison_summary.txt similarity index 100% rename from tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt rename to tests/data/testmodel/testmodel_modelica_comparison_summary.txt diff --git a/tests/baselines/simulation/testmodel/testmodel_primary_series.csv b/tests/data/testmodel/testmodel_primary_series.csv similarity index 100% rename from tests/baselines/simulation/testmodel/testmodel_primary_series.csv rename to tests/data/testmodel/testmodel_primary_series.csv diff --git a/tests/test_analytic_tangent_primitives.py b/tests/test_analytic_tangent_primitives.py new file mode 100644 index 0000000..d5d9685 --- /dev/null +++ b/tests/test_analytic_tangent_primitives.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import unittest +from math import exp + +from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 +from app.simulation.components.amesim.media.mediums import ( + AmesimHeliumPengRobinsonMedium, +) +from app.simulation.components.amesim.mechanical.pistons import AmesimPnrp17 +from app.simulation.components.amesim.mechanical.translational import ( + AmesimLstp00a, + AmesimMecmas21, +) +from app.simulation.components.amesim.storage.chambers import AmesimPnch012 +from app.simulation.core.medium import IdealGasMedium + + +class AnalyticTangentPrimitiveTests(unittest.TestCase): + def assert_tangent_close( + self, + actual: float, + expected: float, + *, + relative: float = 5.0e-5, + absolute: float = 1.0e-8, + ) -> None: + self.assertAlmostEqual( + actual, + expected, + delta=max(absolute, relative * max(abs(actual), abs(expected))), + ) + + def test_peng_robinson_m_u_v_linearization_matches_centered_difference(self) -> None: + medium = AmesimHeliumPengRobinsonMedium() + pressure = 15.3e6 + temperature = 293.15 + volume = 0.01 + mass = medium.density(pressure, temperature) * volume + energy = mass * medium.specific_internal_energy_at_pressure( + pressure, + temperature, + ) + linearization = medium.linearize_properties_from_mU( + mass, + energy, + volume, + (1.0, 0.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + ) + self.assertTrue(linearization.valid, linearization.reason) + + arguments = (mass, energy, volume) + steps = (mass * 1.0e-6, abs(energy) * 1.0e-6, volume * 1.0e-6) + for direction, step in enumerate(steps): + lower = list(arguments) + upper = list(arguments) + lower[direction] -= step + upper[direction] += step + lower_props = medium.properties_from_mU(*lower) + upper_props = medium.properties_from_mU(*upper) + for field in ("p", "T", "rho", "u", "h"): + finite_difference = ( + getattr(upper_props, field) - getattr(lower_props, field) + ) / (2.0 * step) + tangent = getattr(linearization.tangents, field)[direction] + self.assert_tangent_close( + tangent, + finite_difference, + relative=2.0e-4, + absolute=1.0e-6, + ) + + def test_pnrp17_geometry_and_force_tangent_is_exact(self) -> None: + piston = AmesimPnrp17("piston", IdealGasMedium(), dp=0.2, dr=0.01, x0=0.1) + piston.port_4.x = 0.02 + piston.port_5.x = 0.05 + piston.port_4.v = -0.2 + piston.port_5.v = 0.3 + piston.port_1.p = 2.0e5 + result = piston.linearize_geometry_and_force( + (1.0, 0.0), + (0.0, 2.0), + (3.0, 0.0), + (0.0, 4.0), + (5.0, 6.0), + ) + area = piston.effective_area + self.assertTrue(result.valid) + self.assertEqual(result.volume_tangent, (-area, 2.0 * area)) + self.assertEqual(result.volume_flow_tangent, (-3.0 * area, 4.0 * area)) + self.assertEqual(result.pressure_force_tangent, (5.0 * area, 6.0 * area)) + + def test_pnch012_balance_tangent_matches_directional_difference(self) -> None: + chamber = AmesimPnch012( + "chamber", + IdealGasMedium(), + cvol0=0.02, + kth=3.0, + sth=0.4, + p0=2.0e5, + T0=300.0, + ) + chamber.port_1.volume = 0.003 + chamber.port_1.volume_flow = 2.0e-4 + flows = (0.02, -0.01, 0.005, -0.004) + enthalpies = { + "port_1": 330000.0, + "port_2": 310000.0, + "port_3": 320000.0, + "port_4": 300000.0, + } + for port_name, flow in zip( + ("port_1", "port_2", "port_3", "port_4"), + flows, + strict=True, + ): + chamber.get_port(port_name).m_flow = flow + + dm = (1.0e-5,) + dU = (2.0,) + dV = (3.0e-6,) + dVdt = (-4.0e-5,) + dq = { + "port_1": (2.0e-3,), + "port_2": (-1.0e-3,), + "port_3": (3.0e-3,), + "port_4": (-2.0e-3,), + } + dh = { + "port_1": (20.0,), + "port_2": (-10.0,), + "port_3": (30.0,), + "port_4": (-20.0,), + } + result = chamber.linearize_state_derivative( + enthalpies, + state_mass_tangent=dm, + state_energy_tangent=dU, + external_volume_tangent=dV, + external_volume_rate_tangent=dVdt, + port_mass_flow_tangents=dq, + connected_h_tangents=dh, + ) + self.assertTrue(result.valid, result.reason) + + original_state = chamber.get_state_vector() + original_volume = chamber.port_1.volume + original_volume_flow = chamber.port_1.volume_flow + epsilon = 1.0e-4 + + def evaluate(sign: float) -> list[float]: + chamber.set_state_vector( + [ + original_state[0] + sign * epsilon * dm[0], + original_state[1] + sign * epsilon * dU[0], + ] + ) + chamber.port_1.volume = original_volume + sign * epsilon * dV[0] + chamber.port_1.volume_flow = ( + original_volume_flow + sign * epsilon * dVdt[0] + ) + perturbed_h = {} + for port_name in enthalpies: + port = chamber.get_port(port_name) + base_flow = flows[int(port_name[-1]) - 1] + port.m_flow = base_flow + sign * epsilon * dq[port_name][0] + perturbed_h[port_name] = ( + enthalpies[port_name] + sign * epsilon * dh[port_name][0] + ) + return chamber.state_derivative_from_ports(perturbed_h) + + lower = evaluate(-1.0) + upper = evaluate(1.0) + chamber.set_state_vector(original_state) + chamber.port_1.volume = original_volume + chamber.port_1.volume_flow = original_volume_flow + for port_name, flow in zip( + ("port_1", "port_2", "port_3", "port_4"), + flows, + strict=True, + ): + chamber.get_port(port_name).m_flow = flow + for row in range(2): + finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon) + self.assert_tangent_close( + result.tangents[row][0], + finite_difference, + relative=1.0e-5, + absolute=1.0e-6, + ) + + def test_pnl0001_local_flow_slope_and_balance_tangent(self) -> None: + medium = IdealGasMedium() + pipe = AmesimPnl0001( + "pipe", + medium, + diam=0.02, + le=0.5, + kth=2.0, + p0=2.0e5, + T0=300.0, + ) + slope = pipe.linearize_mass_flow(2.2e5, 1.8e5, 300.0) + self.assertTrue(slope.valid, slope.reason) + self.assertGreater(slope.partial_p_1, 0.0) + self.assertLess(slope.partial_p_2, 0.0) + self.assertLess(slope.partial_temperature, 0.0) + boundary = pipe.linearize_mass_flow(2.0e5, 2.0e5, 300.0) + self.assertFalse(boundary.valid) + self.assertEqual(boundary.reason, "flow_direction_boundary") + + pipe.port_1.m_flow = 0.02 + pipe.port_2.m_flow = -0.01 + connected_h = {"port_1": 330000.0, "port_2": 310000.0} + dm = (1.0e-6,) + dU = (0.3,) + dq = {"port_1": (2.0e-3,), "port_2": (-1.0e-3,)} + dh = {"port_1": (20.0,), "port_2": (-10.0,)} + result = pipe.linearize_state_derivative( + connected_h, + state_mass_tangent=dm, + state_energy_tangent=dU, + port_mass_flow_tangents=dq, + connected_h_tangents=dh, + ) + self.assertTrue(result.valid, result.reason) + + original_state = pipe.get_state_vector() + epsilon = 1.0e-4 + + def evaluate(sign: float) -> list[float]: + pipe.set_state_vector( + [ + original_state[0] + sign * epsilon * dm[0], + original_state[1] + sign * epsilon * dU[0], + ] + ) + pipe.port_1.m_flow = 0.02 + sign * epsilon * dq["port_1"][0] + pipe.port_2.m_flow = -0.01 + sign * epsilon * dq["port_2"][0] + return pipe.state_derivative_from_ports( + { + name: value + sign * epsilon * dh[name][0] + for name, value in connected_h.items() + } + ) + + lower = evaluate(-1.0) + upper = evaluate(1.0) + for row in range(2): + finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon) + self.assert_tangent_close( + result.tangents[row][0], + finite_difference, + relative=1.0e-5, + absolute=1.0e-6, + ) + + def test_lstp_fixed_mode_tangent_and_boundary_guard(self) -> None: + contact = AmesimLstp00a( + "contact", + IdealGasMedium(), + gap0=0.001, + kcont=2000.0, + rcont=10.0, + Pdis=0.0005, + ) + contact.port_1.x = 0.002 + contact.port_2.x = 0.0 + contact.port_1.v = 0.3 + contact.port_2.v = -0.1 + result = contact.linearize_contact_force( + (0.4,), + (-0.2,), + (0.3,), + (-0.1,), + ) + self.assertTrue(result.valid, result.reason) + epsilon = 1.0e-7 + originals = ( + contact.port_1.x, + contact.port_2.x, + contact.port_1.v, + contact.port_2.v, + ) + + def evaluate(sign: float) -> float: + contact.port_1.x = originals[0] + sign * epsilon * 0.4 + contact.port_2.x = originals[1] + sign * epsilon * -0.2 + contact.port_1.v = originals[2] + sign * epsilon * 0.3 + contact.port_2.v = originals[3] + sign * epsilon * -0.1 + return contact.contact_force + + finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) + self.assert_tangent_close(result.force_tangent[0], finite_difference) + + contact.clear_causal_contact() + contact.port_1.x = contact.gap0 + contact.port_2.x = 0.0 + boundary = contact.linearize_contact_force( + (1.0,), + (0.0,), + (0.0,), + (0.0,), + ) + self.assertFalse(boundary.valid) + self.assertEqual(boundary.reason, "contact_mode_boundary") + + def test_lstp_causal_contact_retains_a_differentiable_local_mode(self) -> None: + contact = AmesimLstp00a( + "causal_contact", + IdealGasMedium(), + gap0=0.0, + kcont=3000.0, + rcont=12.0, + Pdis=0.001, + ) + contact.port_1.x = 1000.0 + contact.port_2.x = 1000.0 + contact.port_1.v = 0.2 + contact.port_2.v = -0.1 + penetration = 0.002 + damping_fraction = 1.0 - exp(-penetration / contact.Pdis) + force = ( + contact.kcont * penetration + + damping_fraction * contact.rcont * contact.penetration_velocity + ) + contact.set_causal_contact(penetration=penetration, force=force) + result = contact.linearize_contact_force( + (0.3,), + (-0.2,), + (0.4,), + (-0.1,), + ) + self.assertTrue(result.valid, result.reason) + epsilon = 1.0e-6 + originals = ( + contact.port_1.x, + contact.port_2.x, + contact.port_1.v, + contact.port_2.v, + ) + + def evaluate(sign: float) -> float: + contact.port_1.x = originals[0] + sign * epsilon * 0.3 + contact.port_2.x = originals[1] + sign * epsilon * -0.2 + contact.port_1.v = originals[2] + sign * epsilon * 0.4 + contact.port_2.v = originals[3] + sign * epsilon * -0.1 + return contact.contact_force + + finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) + self.assert_tangent_close( + result.force_tangent[0], + finite_difference, + relative=2.0e-5, + ) + + def test_mecmas_soft_endstop_tangents_match_centered_difference(self) -> None: + cases = ( + { + "name": "lower", + "x0": -0.02, + "xmin": 0.0, + "xmax": 1.0, + }, + { + "name": "upper", + "x0": 1.02, + "xmin": 0.0, + "xmax": 1.0, + }, + ) + for case in cases: + with self.subTest(side=case["name"]): + mass = AmesimMecmas21( + f"mass_{case['name']}", + IdealGasMedium(), + mass=2.0, + useFriction=1.0, + stoptype=2.0, + discContactOption=1.0, + Kbmin=1000.0, + Dbmin=20.0, + Pdmin=0.1, + Kbmax=1200.0, + Dbmax=30.0, + Pdmax=0.1, + v0=0.3, + x0=case["x0"], + xmin=case["xmin"], + xmax=case["xmax"], + ) + mass.port_1.f = 5.0 + mass.port_2.f = -1.0 + result = mass.linearize_state_derivative( + (0.2,), + (-0.1,), + (-0.25,), + (0.4,), + constraint_mode="free", + ) + self.assertTrue(result.valid, result.reason) + epsilon = 1.0e-7 + original_v = mass.v + original_x = mass.x + + def evaluate(sign: float) -> float: + mass.v = original_v + sign * epsilon * -0.25 + mass.x = original_x + sign * epsilon * 0.4 + mass.port_1.f = 5.0 + sign * epsilon * 0.2 + mass.port_2.f = -1.0 + sign * epsilon * -0.1 + return mass.unconstrained_acceleration() + + finite_difference = ( + evaluate(1.0) - evaluate(-1.0) + ) / (2.0 * epsilon) + self.assert_tangent_close( + result.tangents[0][0], + finite_difference, + relative=1.0e-6, + ) + + def test_mecmas_free_and_fixed_mode_tangents(self) -> None: + mass = AmesimMecmas21( + "mass", + IdealGasMedium(), + mass=2.0, + useFriction=2.0, + rvisc=3.0, + wind=0.5, + fcoul=0.0, + stoptype=4.0, + v0=2.0, + ) + mass.port_1.f = 10.0 + mass.port_2.f = -2.0 + result = mass.linearize_state_derivative( + (0.3,), + (-0.1,), + (0.2,), + (0.4,), + constraint_mode="free", + ) + self.assertTrue(result.valid, result.reason) + epsilon = 1.0e-6 + original_v = mass.v + original_x = mass.x + + def evaluate(sign: float) -> float: + mass.v = original_v + sign * epsilon * 0.2 + mass.x = original_x + sign * epsilon * 0.4 + mass.port_1.f = 10.0 + sign * epsilon * 0.3 + mass.port_2.f = -2.0 + sign * epsilon * -0.1 + return mass.unconstrained_acceleration() + + finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) + self.assert_tangent_close(result.tangents[0][0], finite_difference) + self.assertEqual(result.tangents[1], (0.2,)) + + mass.set_constraint_motion(0.0, velocity=0.0) + fixed = mass.linearize_state_derivative( + (1.0,), + (1.0,), + (1.0,), + (1.0,), + constraint_mode="lower", + ) + self.assertTrue(fixed.valid, fixed.reason) + self.assertEqual(fixed.tangents, ((0.0,), (0.0,))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core_solver.py b/tests/test_core_solver.py index ac7c393..786fb0d 100644 --- a/tests/test_core_solver.py +++ b/tests/test_core_solver.py @@ -6,6 +6,7 @@ from unittest.mock import patch from app.simulation.core.errors import RecoverableTrialStateError from app.simulation.solvers.solver import ( + IntegrationCancelled, SolveIVPConfig, StateTransition, integrate_ode, @@ -46,6 +47,55 @@ class IntegrateOdeTests(unittest.TestCase): def test_generic_solver_keeps_canonical_default_tolerance(self) -> None: self.assertEqual(SolveIVPConfig().atol, 1.0e-8) + def test_stepwise_jacobian_cancellation_returns_cancelled_solution(self) -> None: + class CancellingJacobian: + def start_segment(self) -> None: + pass + + def observe(self, *_args) -> None: + pass + + def __call__(self, _time, _state): + raise IntegrationCancelled + + result = integrate_ode( + rhs=lambda _time, state: [-float(state[0])], + initial_state=[1.0], + config=SolveIVPConfig(t_stop=1.0, method="BDF"), + cancel_check=lambda: False, + jac=CancellingJacobian(), + ) + + self.assertFalse(result.success) + self.assertEqual(result.status, "cancelled") + self.assertEqual(result.t, [0.0]) + + def test_stepwise_explicit_solver_ignores_callable_jacobian(self) -> None: + class UnexpectedJacobian: + call_count = 0 + + def start_segment(self) -> None: + self.call_count += 1 + + def observe(self, *_args) -> None: + self.call_count += 1 + + def __call__(self, _time, _state): + self.call_count += 1 + raise AssertionError("Explicit solver evaluated Jacobian") + + jacobian = UnexpectedJacobian() + result = integrate_ode( + rhs=lambda _time, _state: [1.0], + initial_state=[0.0], + config=SolveIVPConfig(t_stop=0.01, method="RK45"), + state_transition_handler=lambda *_args: None, + jac=jacobian, + ) + + self.assertTrue(result.success, result.message) + self.assertEqual(jacobian.call_count, 0) + def test_scipy_solver_receives_step_size_controls(self) -> None: calls: list[dict[str, object]] = [] @@ -77,6 +127,57 @@ class IntegrateOdeTests(unittest.TestCase): self.assertEqual(calls[0]["max_step"], 1.0e-4) self.assertEqual(calls[0]["first_step"], 1.0e-8) + def test_direct_implicit_solver_receives_callable_jacobian(self) -> None: + calls: list[dict[str, object]] = [] + observed: list[tuple[float, list[float], list[float]]] = [] + + class RecordingJacobian: + def __init__(self) -> None: + self.segment_count = 0 + + def start_segment(self) -> None: + self.segment_count += 1 + + def observe(self, time, state, derivative) -> None: + observed.append( + ( + float(time), + [float(value) for value in state], + [float(value) for value in derivative], + ) + ) + + def __call__(self, _time, _state): + return [[1.0]] + + def fake_solve_ivp(**kwargs): + calls.append(kwargs) + kwargs["fun"](0.0, [2.0]) + return object() + + scipy_module = types.ModuleType("scipy") + integrate_module = types.ModuleType("scipy.integrate") + integrate_module.solve_ivp = fake_solve_ivp + scipy_module.integrate = integrate_module + jacobian = RecordingJacobian() + + with patch.dict( + sys.modules, + {"scipy": scipy_module, "scipy.integrate": integrate_module}, + ): + integrate_ode( + rhs=lambda _time, state: [2.0 * state[0]], + initial_state=[1.0], + config=SolveIVPConfig(t_start=0.0, t_stop=1.0, method="BDF"), + jac_sparsity=[[True]], + jac=jacobian, + ) + + self.assertIs(calls[0]["jac"], jacobian) + self.assertNotIn("jac_sparsity", calls[0]) + self.assertEqual(jacobian.segment_count, 1) + self.assertEqual(observed, [(0.0, [2.0], [4.0])]) + def test_scipy_solver_omits_unset_first_step(self) -> None: calls: list[dict[str, object]] = [] @@ -416,6 +517,78 @@ class IntegrateOdeTests(unittest.TestCase): ) ) + def test_stepwise_implicit_solver_restarts_callable_jacobian(self) -> None: + import numpy as np + import scipy.integrate + + for method in ("BDF", "Radau"): + with self.subTest(method=method): + received: list[dict[str, object]] = [] + + class RecordingJacobian: + def __init__(self) -> None: + self.segment_count = 0 + self.observation_count = 0 + + def start_segment(self) -> None: + self.segment_count += 1 + + def observe(self, _time, _state, _derivative) -> None: + self.observation_count += 1 + + def __call__(self, _time, _state): + return [[0.0]] + + class RecordingSolver: + def __init__(self, fun, t0, y0, t_bound, **kwargs): + received.append(kwargs) + self.fun = fun + self.t = float(t0) + self.y = np.asarray(y0, dtype=float) + self.t_bound = float(t_bound) + self.status = "running" + self.nfev = 0 + self.njev = 0 + self.nlu = 0 + + def step(self): + self.fun(self.t, self.y) + self.t = self.t_bound + self.status = "finished" + return None + + def dense_output(self): + state = self.y.copy() + return lambda _time: state.copy() + + jacobian = RecordingJacobian() + with patch.object(scipy.integrate, method, RecordingSolver): + result = integrate_ode( + rhs=lambda _time, _state: [0.0], + initial_state=[1.0], + config=SolveIVPConfig( + t_start=0.0, + t_stop=1.0, + method=method, + max_step=1.0, + ), + t_eval=[0.0, 0.4, 1.0], + breakpoints=[0.4], + jac_sparsity=[[True]], + jac=jacobian, + ) + + self.assertTrue(result.success, result.message) + self.assertEqual(jacobian.segment_count, 2) + self.assertEqual(jacobian.observation_count, 2) + self.assertEqual(len(received), 2) + self.assertTrue( + all(options.get("jac") is jacobian for options in received) + ) + self.assertTrue( + all("jac_sparsity" not in options for options in received) + ) + def test_segmented_solver_reports_implicit_work_by_event_segment(self) -> None: import numpy as np import scipy.integrate diff --git a/tests/test_generic_jacobian_sparsity.py b/tests/test_generic_jacobian_sparsity.py index 32fe9c2..c48cf3f 100644 --- a/tests/test_generic_jacobian_sparsity.py +++ b/tests/test_generic_jacobian_sparsity.py @@ -1,8 +1,10 @@ from __future__ import annotations from collections.abc import Mapping +import os from types import SimpleNamespace import unittest +from unittest.mock import patch import numpy as np from scipy.integrate._ivp.common import num_jac @@ -14,13 +16,37 @@ from app.simulation.core.base import DynamicComponent from app.simulation.core.ports import PortDefinition from app.simulation.core.medium import IdealGasMedium from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.systems.generic import GenericFluidSystem +from app.simulation.systems.generic import ( + GenericFluidSystem, + _requested_ode_jacobian_mode, +) from app.simulation.systems.network import Endpoint from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project from tests.test_generic_system_xml_simulation import component_node, physical_edge from tests.test_system_xml_protocol import physical_port +class OdeJacobianModeTests(unittest.TestCase): + def test_scipy_is_the_safe_default(self) -> None: + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(_requested_ode_jacobian_mode(), "scipy") + + def test_optimized_and_hybrid_require_explicit_selection(self) -> None: + for value, expected in (("optimized", "optimized"), ("hybrid", "hybrid")): + with self.subTest(value=value), patch.dict( + os.environ, + {"SIMULATION_ODE_JACOBIAN_MODE": value}, + ): + self.assertEqual(_requested_ode_jacobian_mode(), expected) + + def test_invalid_mode_is_rejected(self) -> None: + with patch.dict( + os.environ, + {"SIMULATION_ODE_JACOBIAN_MODE": "unknown"}, + ), self.assertRaisesRegex(ValueError, "must be 'optimized'"): + _requested_ode_jacobian_mode() + + class _DynamicVolumeSource(DynamicComponent): PORTS = (PortDefinition.pneumatic("port"),) state_size = 1 diff --git a/tests/test_generic_system_xml_simulation.py b/tests/test_generic_system_xml_simulation.py index 0ecbda7..0412a45 100644 --- a/tests/test_generic_system_xml_simulation.py +++ b/tests/test_generic_system_xml_simulation.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import json +import os import threading import time import unittest @@ -321,14 +322,18 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): if phase == "integrating" and progress >= 0.2: cancel_event.set() - result = GenericFluidSystem( - compile_reactflow_network(chain_project()) - ).simulate( - SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001), - sample_step=0.005, - progress_callback=request_cancel_after_progress, - cancel_check=cancel_event.is_set, - ) + with patch.dict( + os.environ, + {"SIMULATION_ODE_JACOBIAN_MODE": "optimized"}, + ): + result = GenericFluidSystem( + compile_reactflow_network(chain_project()) + ).simulate( + SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001), + sample_step=0.005, + progress_callback=request_cancel_after_progress, + cancel_check=cancel_event.is_set, + ) self.assertFalse(result.success) self.assertEqual(result.status, "cancelled") @@ -343,6 +348,14 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): sparsity = integration["jacobianSparsity"] self.assertGreater(sparsity["nonzeroCount"], 0) self.assertGreater(sparsity["colorGroupCount"], 0) + self.assertEqual( + integration["jacobian"]["mode"], + "scipySparseFiniteDifference", + ) + self.assertEqual( + integration["jacobian"]["fallbackReason"], + "denseStateDependencyPattern", + ) self.assertEqual( integration["totals"]["finiteDifferenceRhsEstimate"], sum( diff --git a/tests/test_sparse_secant_jacobian.py b/tests/test_sparse_secant_jacobian.py new file mode 100644 index 0000000..100d082 --- /dev/null +++ b/tests/test_sparse_secant_jacobian.py @@ -0,0 +1,950 @@ +from __future__ import annotations + +import unittest + +import numpy as np +from scipy.integrate._ivp.common import num_jac +from scipy.optimize._numdiff import group_columns +from scipy.sparse import csc_matrix, csr_matrix + +from app.simulation.solvers.jacobian import ( + ExactColumnsUnavailable, + SparseSecantJacobian, +) +from app.simulation.solvers.solver import IntegrationCancelled + + +class _SwitchableLinearRhs: + def __init__(self, matrix: np.ndarray) -> None: + self.matrix = np.asarray(matrix, dtype=float) + self.evaluation_count = 0 + + def __call__(self, time: float, state: np.ndarray) -> np.ndarray: + del time + self.evaluation_count += 1 + return self.matrix @ np.asarray(state, dtype=float) + + +class SparseSecantJacobianTests(unittest.TestCase): + def test_first_call_builds_full_sparse_finite_difference_jacobian(self) -> None: + matrix = np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, 3.0, 0.0), + (4.0, 0.0, 5.0), + ) + ) + evaluator = _SwitchableLinearRhs(matrix) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(matrix != 0.0), + atol=np.full(3, 1.0e-8), + ) + + jacobian = builder(0.0, np.asarray((1.0, -2.0, 0.5))) + + np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["fullBuildCount"], 1) + self.assertEqual(diagnostics["secantReuseCount"], 0) + self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) + self.assertGreater( + diagnostics["finiteDifferenceRhsEvaluationCount"], + 0, + ) + self.assertEqual( + evaluator.evaluation_count, + diagnostics["baseRhsEvaluationCount"] + + diagnostics["finiteDifferenceRhsEvaluationCount"], + ) + + def test_same_time_secant_is_reused_once_after_directional_audit(self) -> None: + matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) + evaluator = _SwitchableLinearRhs(matrix) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + ) + builder(0.0, np.asarray((1.0, 1.0))) + first = np.asarray((1.5, -0.5)) + second = np.asarray((1.75, -0.25)) + builder.observe(0.1, first, matrix @ first) + builder.observe(0.1, second, matrix @ second) + + reused = builder(0.1, second) + + np.testing.assert_allclose(reused.toarray(), matrix, rtol=1.0e-7) + after_reuse = builder.diagnostics() + self.assertEqual(after_reuse["fullBuildCount"], 1) + self.assertEqual(after_reuse["secantReuseCount"], 1) + self.assertEqual(after_reuse["jvAuditEvaluationCount"], 1) + self.assertEqual(after_reuse["lastDecision"], "secantReuse") + self.assertEqual(after_reuse["jacobianEvaluationCount"], 2) + self.assertEqual( + after_reuse["segments"][0]["jvAuditRhsEvaluationCount"], + 1, + ) + self.assertGreater(after_reuse["segments"][0]["assemblySeconds"], 0.0) + + rebuilt = builder(0.1, second) + + np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7) + self.assertEqual(builder.diagnostics()["fullBuildCount"], 2) + + def test_failed_directional_audit_falls_back_to_full_refresh(self) -> None: + initial_matrix = np.eye(2) + changed_matrix = np.asarray(((1.0, 0.0), (0.0, 10.0))) + evaluator = _SwitchableLinearRhs(initial_matrix) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + audit_relative_tolerance=1.0e-3, + ) + builder(0.0, np.asarray((1.0, 1.0))) + evaluator.matrix = changed_matrix + first = np.asarray((1.0, 1.0)) + second = np.asarray((2.0, 1.0)) + builder.observe(0.2, first, changed_matrix @ first) + builder.observe(0.2, second, changed_matrix @ second) + + refreshed = builder(0.2, second) + + np.testing.assert_allclose( + refreshed.toarray(), + changed_matrix, + rtol=1.0e-7, + ) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["fullBuildCount"], 2) + self.assertEqual(diagnostics["secantReuseCount"], 0) + self.assertEqual(diagnostics["auditFailureCount"], 1) + self.assertEqual(diagnostics["jvAuditEvaluationCount"], 1) + self.assertEqual(diagnostics["lastDecision"], "auditFallback") + + def test_directional_audit_does_not_swallow_cancellation(self) -> None: + matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) + cancel_next = False + + def evaluator(_time: float, state: np.ndarray) -> np.ndarray: + nonlocal cancel_next + if cancel_next: + cancel_next = False + raise IntegrationCancelled + return matrix @ np.asarray(state, dtype=float) + + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + ) + builder(0.0, np.asarray((1.0, 1.0))) + first = np.asarray((1.5, -0.5)) + second = np.asarray((1.75, -0.25)) + builder.observe(0.1, first, matrix @ first) + builder.observe(0.1, second, matrix @ second) + cancel_next = True + + with self.assertRaises(IntegrationCancelled): + builder(0.1, second) + + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["fullBuildCount"], 1) + self.assertEqual(diagnostics["auditFailureCount"], 0) + self.assertEqual(diagnostics["secantReuseCount"], 0) + + def test_segment_reset_forces_a_new_full_build(self) -> None: + matrix = np.asarray(((3.0, 0.0), (0.0, -2.0))) + evaluator = _SwitchableLinearRhs(matrix) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(matrix != 0.0), + atol=1.0e-8, + ) + state = np.asarray((2.0, 4.0)) + builder(0.0, state) + + builder.start_segment() + rebuilt = builder(1.0, state) + + np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["segmentStartCount"], 1) + self.assertEqual(diagnostics["fullBuildCount"], 2) + self.assertEqual(diagnostics["lastDecision"], "fullBuild") + self.assertEqual(len(diagnostics["segments"]), 2) + self.assertEqual(diagnostics["segments"][0]["fullBuildCount"], 1) + self.assertEqual(diagnostics["segments"][1]["fullBuildCount"], 1) + + def test_exact_row_replaces_finite_difference_and_secant_row(self) -> None: + def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: + del time + return np.asarray( + ( + state[0] * state[0] + 3.0 * state[1], + -2.0 * state[0] + state[1], + ) + ) + + builder = SparseSecantJacobian( + nonlinear_rhs, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + exact_rows={0: {0: 7.0, 1: 8.0}}, + ) + + jacobian = builder(0.0, np.asarray((2.0, 1.0))).toarray() + + np.testing.assert_array_equal(jacobian[0], np.asarray((7.0, 8.0))) + np.testing.assert_allclose(jacobian[1], np.asarray((-2.0, 1.0)), rtol=1.0e-7) + + def test_exact_columns_match_dense_num_jac_and_use_normalized_order(self) -> None: + def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: + del time + return np.asarray( + ( + state[0] * state[0] + state[1] * state[2], + np.sin(state[0]) + 3.0 * state[1] - state[2], + np.exp(state[2]) + state[0] * state[1], + ) + ) + + provider_calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = [] + + def exact_column_provider( + time: float, + state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + provider_calls.append((time, state.copy(), columns)) + self.assertEqual(columns, (0, 2)) + return np.asarray( + ( + (2.0 * state[0], state[1]), + (np.cos(state[0]), -1.0), + (state[1], np.exp(state[2])), + ) + ) + + state = np.asarray((1.25, -0.75, 0.2)) + atol = np.full(3, 1.0e-8) + structure = csc_matrix(np.ones((3, 3), dtype=bool)) + base_rhs = nonlinear_rhs(0.3, state) + + def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray: + states_array = np.asarray(states, dtype=float) + if states_array.ndim == 1: + return nonlinear_rhs(time, states_array) + return np.column_stack( + [ + nonlinear_rhs(time, states_array[:, column]) + for column in range(states_array.shape[1]) + ] + ) + + expected, _factor = num_jac( + vectorized_rhs, + 0.3, + state, + base_rhs, + atol, + None, + ) + builder = SparseSecantJacobian( + nonlinear_rhs, + structure, + atol=atol, + exact_columns=((2, 0), exact_column_provider), + max_consecutive_reuses=0, + ) + + actual = builder(0.3, state).toarray() + + np.testing.assert_allclose(actual, expected, rtol=2.0e-7, atol=2.0e-8) + self.assertEqual(len(provider_calls), 1) + self.assertEqual(provider_calls[0][0], 0.3) + np.testing.assert_array_equal(provider_calls[0][1], state) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["exactColumnCount"], 2) + self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 1) + self.assertEqual(diagnostics["colorGroupCount"], 1) + self.assertEqual( + diagnostics["exactColumnOutsidePatternNonzeroCount"], + 0, + ) + + def test_exact_columns_reduce_seed_zero_coloring(self) -> None: + matrix = np.asarray( + ( + (1.0, 2.0, 3.0, 4.0), + (5.0, 6.0, 7.0, 8.0), + (9.0, 10.0, 11.0, 12.0), + (13.0, 14.0, 15.0, 16.0), + ) + ) + structure = csc_matrix(np.ones((4, 4), dtype=bool)) + baseline = SparseSecantJacobian( + _SwitchableLinearRhs(matrix), + structure, + atol=1.0e-8, + max_consecutive_reuses=0, + ) + reduced = SparseSecantJacobian( + _SwitchableLinearRhs(matrix), + structure, + atol=1.0e-8, + exact_columns={1: matrix[:, 1], 3: matrix[:, 3]}, + max_consecutive_reuses=0, + ) + + self.assertEqual(baseline.diagnostics()["colorGroupCount"], 4) + diagnostics = reduced.diagnostics() + self.assertEqual(diagnostics["coloringSeed"], 0) + self.assertEqual(diagnostics["colorGroupCount"], 2) + self.assertEqual(diagnostics["exactColumnCount"], 2) + self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 2) + + def test_exact_column_nonzeros_outside_pattern_are_preserved(self) -> None: + evaluator = _SwitchableLinearRhs(np.eye(3)) + builder = SparseSecantJacobian( + evaluator, + csc_matrix(np.eye(3, dtype=bool)), + atol=1.0e-8, + exact_columns={1: np.asarray((4.0, 5.0, 6.0))}, + max_consecutive_reuses=0, + ) + + jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray() + + np.testing.assert_array_equal(jacobian[:, 1], np.asarray((4.0, 5.0, 6.0))) + self.assertEqual( + builder.diagnostics()["exactColumnOutsidePatternNonzeroCount"], + 2, + ) + + def test_exact_rows_override_exact_column_intersections(self) -> None: + builder = SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + csc_matrix(np.eye(3, dtype=bool)), + atol=1.0e-8, + exact_rows={0: {0: 9.0, 1: 10.0}}, + exact_columns={1: np.asarray((4.0, 5.0, 6.0))}, + max_consecutive_reuses=0, + ) + + jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray() + + np.testing.assert_array_equal(jacobian[0], np.asarray((9.0, 10.0, 0.0))) + np.testing.assert_array_equal(jacobian[1:, 1], np.asarray((5.0, 6.0))) + + def test_all_exact_columns_skip_every_finite_difference_rhs(self) -> None: + matrix = np.asarray(((2.0, -1.0), (3.0, 4.0))) + provider_calls = 0 + evaluator = _SwitchableLinearRhs(matrix) + + def exact_column_provider( + time: float, + state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + nonlocal provider_calls + del time, state + provider_calls += 1 + self.assertEqual(columns, (0, 1)) + return matrix + + builder = SparseSecantJacobian( + evaluator, + csc_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + exact_columns=((1, 0), exact_column_provider), + max_consecutive_reuses=0, + ) + + jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray() + + np.testing.assert_array_equal(jacobian, matrix) + self.assertEqual(provider_calls, 1) + self.assertEqual(evaluator.evaluation_count, 1) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["colorGroupCount"], 0) + self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 0) + self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) + self.assertEqual(diagnostics["finiteDifferenceRhsEvaluationCount"], 0) + + def test_exact_column_provider_runs_after_base_and_before_fd(self) -> None: + matrix = np.asarray( + ( + (2.0, 1.0, 0.0), + (0.0, 3.0, 4.0), + (5.0, 0.0, 6.0), + ) + ) + state = np.asarray((1.0, 2.0, 3.0)) + events: list[tuple[str, int]] = [] + evaluated_states: list[np.ndarray] = [] + rhs_epoch = 0 + + def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: + nonlocal rhs_epoch + del time + rhs_epoch += 1 + events.append(("rhs", rhs_epoch)) + evaluated_states.append(evaluation_state.copy()) + return matrix @ evaluation_state + + def provider( + time: float, + provider_state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + del time + events.append(("provider", rhs_epoch)) + self.assertEqual(rhs_epoch, 1) + self.assertEqual(columns, (1,)) + np.testing.assert_array_equal(provider_state, state) + return matrix[:, [1]] + + builder = SparseSecantJacobian( + evaluator, + csc_matrix(matrix != 0.0), + atol=1.0e-8, + exact_columns=((1,), provider), + max_consecutive_reuses=0, + ) + + builder(0.0, state) + + self.assertEqual(events[0], ("rhs", 1)) + self.assertEqual(events[1], ("provider", 1)) + self.assertTrue(any(event[0] == "rhs" for event in events[2:])) + diagnostics = builder.diagnostics() + self.assertEqual( + len(evaluated_states), + 1 + diagnostics["colorGroupCount"], + ) + self.assertEqual( + diagnostics["finiteDifferenceRhsEvaluationCount"], + diagnostics["colorGroupCount"], + ) + for perturbed_state in evaluated_states[1:]: + self.assertEqual(perturbed_state[1], state[1]) + self.assertTrue(np.any(perturbed_state[[0, 2]] != state[[0, 2]])) + + def test_exact_column_capture_request_is_always_cancelled(self) -> None: + matrix = np.asarray(((2.0, 1.0), (0.0, 3.0))) + + class Provider: + def __init__(self) -> None: + self.pending = False + self.request_count = 0 + self.cancel_count = 0 + self.call_count = 0 + + def request_primal_capture(self) -> None: + self.request_count += 1 + self.pending = True + + def cancel_primal_capture(self) -> None: + self.cancel_count += 1 + self.pending = False + + def __call__( + self, + time: float, + state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + del time, state + self.call_count += 1 + self.assert_capture_was_cancelled(columns) + return matrix[:, [1]] + + def assert_capture_was_cancelled( + self, + columns: tuple[int, ...], + ) -> None: + if self.pending or columns != (1,): + raise AssertionError("The one-shot capture request leaked.") + + provider = Provider() + builder = SparseSecantJacobian( + _SwitchableLinearRhs(matrix), + csc_matrix(matrix != 0.0), + atol=1.0e-8, + exact_columns=((1,), provider), + max_consecutive_reuses=0, + ) + + jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray() + + np.testing.assert_allclose(jacobian, matrix, rtol=1.0e-7) + self.assertEqual(provider.request_count, 1) + self.assertEqual(provider.cancel_count, 1) + self.assertEqual(provider.call_count, 1) + self.assertFalse(provider.pending) + + def test_exact_column_provider_output_is_validated_and_errors_propagate(self) -> None: + state = np.asarray((1.0, 2.0, 3.0)) + structure = csc_matrix(np.eye(3, dtype=bool)) + + with self.assertRaisesRegex(ValueError, "duplicated"): + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + structure, + atol=1.0e-8, + exact_columns=((1, 1), lambda *_args: np.zeros((3, 2))), + ) + with self.assertRaisesRegex(ValueError, "out of range"): + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + structure, + atol=1.0e-8, + exact_columns=((3,), lambda *_args: np.zeros((3, 1))), + ) + + invalid_builders = ( + ( + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + structure, + atol=1.0e-8, + exact_columns=((0, 2), lambda *_args: np.zeros((2, 3))), + max_consecutive_reuses=0, + ), + "must return shape", + ), + ( + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + structure, + atol=1.0e-8, + exact_columns=( + (0, 2), + lambda *_args: np.asarray( + ((1.0, 0.0), (0.0, np.nan), (0.0, 1.0)) + ), + ), + max_consecutive_reuses=0, + ), + "finite values", + ), + ( + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(3)), + structure, + atol=1.0e-8, + exact_columns=( + (0, 2), + lambda *_args: { + 2: np.zeros(3), + 0: np.zeros(3), + }, + ), + max_consecutive_reuses=0, + ), + "normalized column order", + ), + ) + for builder, message in invalid_builders: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + builder(0.0, state) + + class ProviderFailure(RuntimeError): + pass + + def failing_provider(*_args): + raise ProviderFailure("provider failed") + + failure_evaluator = _SwitchableLinearRhs(np.eye(3)) + failing_builder = SparseSecantJacobian( + failure_evaluator, + structure, + atol=1.0e-8, + exact_columns=((0,), failing_provider), + max_consecutive_reuses=0, + ) + with self.assertRaisesRegex(ProviderFailure, "provider failed"): + failing_builder(0.0, state) + self.assertEqual(failure_evaluator.evaluation_count, 1) + failure_diagnostics = failing_builder.diagnostics() + self.assertEqual(failure_diagnostics["baseRhsEvaluationCount"], 1) + self.assertEqual( + failure_diagnostics["finiteDifferenceRhsEvaluationCount"], + 0, + ) + + def test_exact_column_provider_is_refreshed_after_segment_reset(self) -> None: + calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = [] + + def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: + return np.asarray( + ( + 2.0 * state[0] + (1.0 + time) * state[1], + state[0] * state[1], + ) + ) + + def provider( + time: float, + state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + calls.append((time, state.copy(), columns)) + return np.asarray(((1.0 + time,), (state[0],))) + + builder = SparseSecantJacobian( + nonlinear_rhs, + csc_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + exact_columns=((1,), provider), + max_consecutive_reuses=0, + ) + first_state = np.asarray((1.0, 2.0)) + second_state = np.asarray((3.0, 4.0)) + + first = builder(0.0, first_state).toarray() + builder.start_segment() + second = builder(1.0, second_state).toarray() + + np.testing.assert_allclose(first[:, 1], np.asarray((1.0, 1.0))) + np.testing.assert_allclose(second[:, 1], np.asarray((2.0, 3.0))) + self.assertEqual([call[0] for call in calls], [0.0, 1.0]) + self.assertEqual([call[2] for call in calls], [(1,), (1,)]) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["fullBuildCount"], 2) + self.assertEqual(diagnostics["segmentStartCount"], 1) + self.assertEqual(len(diagnostics["segments"]), 2) + + def test_exact_column_unavailable_falls_back_and_next_build_recovers(self) -> None: + matrix = np.asarray( + ( + (2.0, 1.0, 3.0, -1.0), + (4.0, 5.0, -2.0, 6.0), + (7.0, -3.0, 8.0, 2.0), + (-4.0, 9.0, 1.0, 10.0), + ) + ) + state = np.asarray((1.0, -2.0, 0.5, 3.0)) + evaluated_states: list[np.ndarray] = [] + provider_call_count = 0 + + def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: + del time + evaluated_states.append(evaluation_state.copy()) + return matrix @ evaluation_state + + def provider( + time: float, + provider_state: np.ndarray, + columns: tuple[int, ...], + ) -> np.ndarray: + nonlocal provider_call_count + del time, provider_state + provider_call_count += 1 + if provider_call_count == 1: + raise ExactColumnsUnavailable("tangent domain boundary") + return matrix[:, columns] + + structure = csc_matrix(np.ones((4, 4), dtype=bool)) + builder = SparseSecantJacobian( + evaluator, + structure, + atol=1.0e-8, + exact_columns=((3, 1), provider), + max_consecutive_reuses=0, + ) + baseline = SparseSecantJacobian( + _SwitchableLinearRhs(matrix), + structure, + atol=1.0e-8, + max_consecutive_reuses=0, + ) + + fallback = builder(0.0, state).toarray() + expected_fallback = baseline(0.0, state).toarray() + + np.testing.assert_array_equal(fallback, expected_fallback) + self.assertEqual(provider_call_count, 1) + self.assertTrue( + any( + evaluation_state[1] != state[1] + for evaluation_state in evaluated_states[1:] + ) + ) + self.assertTrue( + any( + evaluation_state[3] != state[3] + for evaluation_state in evaluated_states[1:] + ) + ) + fallback_diagnostics = builder.diagnostics() + self.assertEqual(fallback_diagnostics["baseRhsEvaluationCount"], 1) + self.assertEqual(fallback_diagnostics["originalColorGroupCount"], 4) + self.assertEqual(fallback_diagnostics["remainingColorGroupCount"], 2) + self.assertEqual(fallback_diagnostics["exactColumnBuildCount"], 0) + self.assertEqual(fallback_diagnostics["exactColumnFallbackCount"], 1) + self.assertEqual( + fallback_diagnostics["lastExactColumnFallbackReason"], + "tangent domain boundary", + ) + self.assertEqual( + fallback_diagnostics[ + "lastBuildFiniteDifferenceRhsEvaluationCount" + ], + 4, + ) + self.assertIsNotNone(builder._original_factor) + self.assertIsNone(builder._remaining_factor) + + recovery_start = len(evaluated_states) + recovered = builder(0.1, state).toarray() + recovery_states = evaluated_states[recovery_start:] + + np.testing.assert_allclose(recovered, matrix, rtol=1.0e-7) + self.assertEqual(provider_call_count, 2) + self.assertEqual(len(recovery_states), 3) + for perturbed_state in recovery_states[1:]: + self.assertEqual(perturbed_state[1], state[1]) + self.assertEqual(perturbed_state[3], state[3]) + recovery_diagnostics = builder.diagnostics() + self.assertEqual(recovery_diagnostics["exactColumnBuildCount"], 1) + self.assertEqual(recovery_diagnostics["exactColumnFallbackCount"], 1) + self.assertEqual(recovery_diagnostics["baseRhsEvaluationCount"], 2) + self.assertEqual( + recovery_diagnostics[ + "lastBuildFiniteDifferenceRhsEvaluationCount" + ], + 2, + ) + self.assertIsNotNone(builder._remaining_factor) + self.assertIsNot(builder._original_factor, builder._remaining_factor) + + def test_exact_column_subset_retry_matches_scipy_active_columns(self) -> None: + offset = np.asarray((1.0e8, -3.0e8, 2.0e8)) + slopes = np.asarray((1.0, 2.0, -3.0)) + state = np.asarray((1.0, -2.0, 0.5)) + evaluated_states: list[np.ndarray] = [] + + def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: + del time + evaluated_states.append(evaluation_state.copy()) + return offset + slopes * evaluation_state + + builder = SparseSecantJacobian( + evaluator, + csc_matrix(np.eye(3, dtype=bool)), + atol=1.0e-8, + exact_columns={1: np.asarray((0.0, slopes[1], 0.0))}, + max_consecutive_reuses=0, + ) + + actual = builder(0.0, state).toarray() + + active = np.asarray((0, 2)) + active_state = state[active] + active_offset = offset[active] + active_slopes = slopes[active] + + def active_rhs(time: float, states: np.ndarray) -> np.ndarray: + del time + states_array = np.asarray(states, dtype=float) + if states_array.ndim == 1: + return active_offset + active_slopes * states_array + return active_offset[:, None] + active_slopes[:, None] * states_array + + active_structure = csc_matrix(np.eye(2, dtype=bool)) + active_groups = group_columns(active_structure, order=0) + expected, _factor = num_jac( + active_rhs, + 0.0, + active_state, + active_rhs(0.0, active_state), + np.full(2, 1.0e-8), + None, + (active_structure, active_groups), + ) + + np.testing.assert_array_equal( + actual[np.ix_(active, active)], + expected.toarray(), + ) + diagnostics = builder.diagnostics() + self.assertGreater( + diagnostics["lastBuildFiniteDifferenceRhsEvaluationCount"], + diagnostics["remainingColorGroupCount"], + ) + for perturbed_state in evaluated_states[1:]: + self.assertEqual(perturbed_state[1], state[1]) + + def test_audit_step_respects_tiny_state_absolute_tolerance(self) -> None: + evaluator = _SwitchableLinearRhs(np.eye(2)) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=np.asarray((1.0e-12, 1.0e-8)), + ) + state = np.zeros(2) + + step = builder._audit_step(state) + + self.assertGreater(abs(step[0]), 0.0) + self.assertLessEqual(abs(step[0]), 1.0e-12) + self.assertGreater(abs(step[1]), 0.0) + self.assertLessEqual(abs(step[1]), 1.0e-8) + + def test_optimized_finite_difference_skips_secant_observation_work(self) -> None: + matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) + evaluator = _SwitchableLinearRhs(matrix) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + max_consecutive_reuses=0, + ) + state = np.asarray((1.0, -2.0)) + derivative = evaluator(0.0, state) + builder.observe(0.0, state, derivative) + + jacobian = builder(0.0, state) + + np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["mode"], "optimizedSparseFiniteDifference") + self.assertEqual(diagnostics["fullBuildCount"], 1) + self.assertEqual(diagnostics["secantReuseCount"], 0) + self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) + self.assertEqual(diagnostics["secantUpdateCount"], 0) + self.assertEqual(diagnostics["coloringSeed"], 0) + + def test_seed_zero_callable_matches_scipy_num_jac(self) -> None: + def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: + del time + return np.asarray( + ( + state[0] * state[0] + 3.0 * state[1], + np.sin(state[0]) - state[1], + ) + ) + + state = np.asarray((2.0, 1.0)) + atol = np.full(2, 1.0e-8) + structure = csc_matrix(np.ones((2, 2), dtype=bool)) + groups = group_columns(structure, order=0) + base_rhs = nonlinear_rhs(0.0, state) + + def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray: + states_array = np.asarray(states, dtype=float) + if states_array.ndim == 1: + return nonlinear_rhs(time, states_array) + return np.column_stack( + [ + nonlinear_rhs(time, states_array[:, column]) + for column in range(states_array.shape[1]) + ] + ) + + expected, _factor = num_jac( + vectorized_rhs, + 0.0, + state, + base_rhs, + atol, + None, + (structure, groups), + ) + builder = SparseSecantJacobian( + nonlinear_rhs, + structure, + atol=atol, + max_consecutive_reuses=0, + ) + + actual = builder(0.0, state) + + np.testing.assert_array_equal(actual.toarray(), expected.toarray()) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["coloringSeed"], 0) + self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) + + def test_default_coloring_stays_seed_zero_when_another_seed_is_smaller(self) -> None: + structure = csc_matrix( + np.asarray( + ( + (1, 1, 0, 0, 0), + (0, 1, 0, 0, 0), + (0, 0, 1, 0, 0), + (0, 0, 0, 1, 1), + (0, 1, 0, 0, 1), + ), + dtype=bool, + ) + ) + seed_zero_count = int(group_columns(structure, order=0).max()) + 1 + seed_54_count = int(group_columns(structure, order=54).max()) + 1 + self.assertEqual(seed_zero_count, 3) + self.assertEqual(seed_54_count, 2) + + builder = SparseSecantJacobian( + _SwitchableLinearRhs(structure.toarray().astype(float)), + structure, + atol=1.0e-8, + max_consecutive_reuses=0, + ) + + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["coloringSeed"], 0) + self.assertEqual(diagnostics["colorGroupCount"], 3) + self.assertEqual(diagnostics["defaultColorGroupCount"], 3) + + def test_exact_rows_do_not_reorder_seed_zero_perturbation_batches(self) -> None: + structure = csc_matrix(np.asarray(((1, 1), (1, 0)), dtype=bool)) + builder = SparseSecantJacobian( + _SwitchableLinearRhs(np.asarray(((1.0, 1.0), (1.0, 0.0)))), + structure, + atol=1.0e-8, + exact_rows={0: {0: 1.0, 1: 1.0}}, + max_consecutive_reuses=0, + ) + + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["coloringSeed"], 0) + self.assertEqual(diagnostics["colorGroupCount"], 2) + + def test_rejects_more_than_one_consecutive_secant_reuse(self) -> None: + with self.assertRaisesRegex( + ValueError, + "max_consecutive_reuses must be either 0 or 1", + ): + SparseSecantJacobian( + _SwitchableLinearRhs(np.eye(2)), + csr_matrix(np.eye(2, dtype=bool)), + atol=1.0e-8, + max_consecutive_reuses=2, + ) + + def test_uninformative_zero_jv_audit_forces_full_refresh(self) -> None: + evaluator = _SwitchableLinearRhs(np.zeros((2, 2))) + builder = SparseSecantJacobian( + evaluator, + csr_matrix(np.ones((2, 2), dtype=bool)), + atol=1.0e-8, + ) + state = np.asarray((1.0, 1.0)) + builder(0.0, state) + builder.observe(0.1, state, np.zeros(2)) + builder.observe(0.1, state + 1.0, np.zeros(2)) + + refreshed = builder(0.1, state + 1.0) + + np.testing.assert_array_equal(refreshed.toarray(), np.zeros((2, 2))) + diagnostics = builder.diagnostics() + self.assertEqual(diagnostics["secantReuseCount"], 0) + self.assertEqual(diagnostics["auditFailureCount"], 1) + self.assertEqual(diagnostics["fullBuildCount"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_three_piston_tangent.py b/tests/test_three_piston_tangent.py new file mode 100644 index 0000000..4e378ec --- /dev/null +++ b/tests/test_three_piston_tangent.py @@ -0,0 +1,334 @@ +from pathlib import Path +import os +import unittest +from unittest.mock import patch + +import numpy as np + +from app.main import compile_system_xml_network +from app.simulation.solvers.jacobian import ExactColumnsUnavailable +from app.simulation.solvers.mechanical import MechanicalConstraintGroup +from app.simulation.solvers.solver import ODESolution, SolveIVPConfig +from app.simulation.solvers.tangent import compile_three_piston_tangent_provider +from app.simulation.systems.generic import ( + ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE, + GenericFluidSystem, + _requested_ode_jacobian_mode, +) +from app.system_xml import validate_system_xml_document + + +TARGET_XML = Path("tests/data/test_mql-full-branches-01-04.xml") +TARGET_MASS_NAMES = ( + "mass_friction_endstops_10", + "mass_friction_endstops_11", + "mass_friction_endstops_12", +) + + +class ThreePistonTangentCompilerTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + report = validate_system_xml_document(TARGET_XML.read_bytes()) + assert report.valid + with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): + cls.system = GenericFluidSystem( + compile_system_xml_network(report.document) + ) + cls.initial_state = np.asarray( + cls.system.consistent_initial_state_vector(0.0), + dtype=float, + ) + + def setUp(self) -> None: + self.system.mechanical_state_reducer.reset_constraint_modes() + self.system.apply_state_vector(self.initial_state.tolist()) + + def _closed_primal( + self, + state: np.ndarray, + ) -> tuple[dict[str, dict[str, float]], np.ndarray]: + self.system.apply_state_vector(state.tolist()) + connected_h = self.system._close_current_state(0.0) + derivative = np.asarray( + self.system._state_derivatives(connected_h), + dtype=float, + ) + return connected_h, derivative + + def test_target_layout_is_resolved_from_owner_and_slot(self) -> None: + compilation = compile_three_piston_tangent_provider(self.system) + self.assertTrue(compilation.eligible, compilation.reason) + + owner_slots: dict[str, tuple[int, int]] = {} + cursor = 0 + for entry in self.system.mechanical_state_reducer.state_entries: + if isinstance(entry, MechanicalConstraintGroup): + owner_slots[entry.representative.name] = (cursor, cursor + 1) + cursor += 2 + else: + cursor += entry.state_size + resolved = tuple( + sorted( + index + for name in TARGET_MASS_NAMES + for index in owner_slots[name] + ) + ) + + self.assertEqual(compilation.columns, resolved) + # This is a fixture drift guard, not the provider's lookup mechanism. + self.assertEqual(resolved, (20, 21, 38, 39, 54, 55)) + self.assertEqual(compilation.reached_assignment_count, 34) + + def test_stale_primal_context_requests_typed_fallback(self) -> None: + compilation = compile_three_piston_tangent_provider(self.system) + self.assertTrue(compilation.eligible, compilation.reason) + assert compilation.provider is not None + + with self.assertRaises(ExactColumnsUnavailable) as captured: + compilation.provider( + 0.0, + self.initial_state.copy(), + compilation.columns, + ) + self.assertEqual(captured.exception.reason, "stalePrimalContext") + + def test_initial_contact_boundary_requests_numerical_columns(self) -> None: + compilation = compile_three_piston_tangent_provider(self.system) + self.assertTrue(compilation.eligible, compilation.reason) + assert compilation.provider is not None + connected_h, _derivative = self._closed_primal( + self.initial_state.copy() + ) + compilation.provider.request_primal_capture() + compilation.provider.record_primal( + 0.0, + self.initial_state, + connected_h, + ) + + with self.assertRaises(ExactColumnsUnavailable) as captured: + compilation.provider( + 0.0, + self.initial_state.copy(), + compilation.columns, + ) + self.assertEqual( + captured.exception.reason, + "contactMode:contact_mode_boundary", + ) + + def test_smooth_six_columns_match_full_rhs_centered_difference(self) -> None: + compilation = compile_three_piston_tangent_provider(self.system) + self.assertTrue(compilation.eligible, compilation.reason) + provider = compilation.provider + assert provider is not None + + state = self.initial_state.copy() + # Move all three contacts away from gap==0 and all three PNL0001 laws + # away from equal-pressure/zero-flow. The selected columns themselves + # remain the six mechanical [v, x] seeds. + for branch in provider.branches: + chamber_offset, chamber_size = provider.state_offsets[ + branch.chamber.name + ] + self.assertEqual(chamber_size, 2) + state[chamber_offset] *= 1.01 + state[branch.position_index] -= 1.0e-3 + + connected_h, _base = self._closed_primal(state) + provider.request_primal_capture() + provider.record_primal(0.0, state, connected_h) + exact = provider(0.0, state.copy(), compilation.columns) + + numerical = np.empty_like(exact) + for local_column, state_index in enumerate(compilation.columns): + step = 1.0e-7 * max(abs(state[state_index]), 1.0) + lower = state.copy() + upper = state.copy() + lower[state_index] -= step + upper[state_index] += step + lower_rhs = np.asarray( + self.system.rhs(0.0, lower.tolist()), + dtype=float, + ) + upper_rhs = np.asarray( + self.system.rhs(0.0, upper.tolist()), + dtype=float, + ) + numerical[:, local_column] = ( + upper_rhs - lower_rhs + ) / (2.0 * step) + + np.testing.assert_allclose( + exact, + numerical, + rtol=2.0e-6, + atol=1.0e-5, + ) + for downstream_mass in ( + "mass_friction_endstops_18", + "mass_friction_endstops_19", + ): + offset, size = provider.state_offsets[downstream_mass] + self.assertEqual(size, 2) + # Causal force reach includes both remote masses, but this fixture + # holds their discrete constraints fixed, so final ODE rows are zero. + np.testing.assert_allclose( + exact[offset : offset + size, :], + numerical[offset : offset + size, :], + rtol=0.0, + atol=1.0e-12, + ) + np.testing.assert_allclose( + exact[offset : offset + size, :], + 0.0, + rtol=0.0, + atol=1.0e-12, + ) + + def test_disabled_causal_path_is_not_eligible(self) -> None: + solver = self.system.pressure_flow_solver + original = solver._causal_fast_path_environment_enabled + try: + solver._causal_fast_path_environment_enabled = False + compilation = compile_three_piston_tangent_provider(self.system) + finally: + solver._causal_fast_path_environment_enabled = original + self.assertFalse(compilation.eligible) + self.assertEqual(compilation.reason, "causalFastPathDisabled") + + def test_semi_analytic_mode_is_explicit_opt_in(self) -> None: + with patch.dict( + os.environ, + {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, + ): + self.assertEqual( + _requested_ode_jacobian_mode(), + "semi-analytic", + ) + with patch.dict(os.environ, {}, clear=True): + self.assertEqual(_requested_ode_jacobian_mode(), "scipy") + + def test_generic_simulation_wires_exact_columns_and_cleans_provider( + self, + ) -> None: + report = validate_system_xml_document(TARGET_XML.read_bytes()) + assert report.valid and report.document is not None + with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): + system = GenericFluidSystem( + compile_system_xml_network(report.document) + ) + captured: dict[str, object] = {} + + def fake_integrate_ode(**options) -> ODESolution: + captured.update(options) + captured["activeProvider"] = system._ode_tangent_provider + initial = [float(value) for value in options["initial_state"]] + stop = float(options["config"].t_stop) + return ODESolution( + t=[0.0, stop], + y=[[value, value] for value in initial], + success=True, + message="test", + ) + + with patch.dict( + os.environ, + {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, + ), patch( + "app.simulation.systems.generic.integrate_ode", + side_effect=fake_integrate_ode, + ): + result = system.simulate( + SolveIVPConfig(t_stop=1.0e-3, method="BDF"), + sample_step=1.0e-3, + ) + + jacobian = captured["jac"] + self.assertIsNotNone(jacobian) + self.assertIsNotNone(captured["activeProvider"]) + self.assertIsNone(system._ode_tangent_provider) + runtime = result.diagnostics["integration"]["jacobian"] + self.assertEqual(runtime["mode"], "semiAnalyticExactColumns") + self.assertEqual(runtime["effectiveMode"], "notEvaluated") + self.assertEqual(runtime["originalColorGroupCount"], 31) + self.assertEqual(runtime["remainingColorGroupCount"], 25) + self.assertEqual(runtime["exactColumnCount"], 6) + + def test_generic_simulation_ineligible_path_uses_native_scipy(self) -> None: + report = validate_system_xml_document(TARGET_XML.read_bytes()) + assert report.valid and report.document is not None + with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): + system = GenericFluidSystem( + compile_system_xml_network(report.document) + ) + system.pressure_flow_solver._causal_fast_path_environment_enabled = False + captured: dict[str, object] = {} + + def fake_integrate_ode(**options) -> ODESolution: + captured.update(options) + captured["activeProvider"] = system._ode_tangent_provider + initial = [float(value) for value in options["initial_state"]] + stop = float(options["config"].t_stop) + return ODESolution( + t=[0.0, stop], + y=[[value, value] for value in initial], + success=True, + message="test", + ) + + with patch.dict( + os.environ, + {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, + ), patch( + "app.simulation.systems.generic.integrate_ode", + side_effect=fake_integrate_ode, + ): + result = system.simulate( + SolveIVPConfig(t_stop=1.0e-3, method="BDF"), + sample_step=1.0e-3, + ) + + self.assertIsNone(captured["jac"]) + self.assertIsNone(captured["activeProvider"]) + self.assertIsNone(system._ode_tangent_provider) + runtime = result.diagnostics["integration"]["jacobian"] + self.assertEqual(runtime["mode"], "scipySparseFiniteDifference") + self.assertEqual( + runtime["fallbackReason"], + "semiAnalytic:causalFastPathDisabled", + ) + + def test_generic_simulation_cleans_provider_after_integration_error( + self, + ) -> None: + report = validate_system_xml_document(TARGET_XML.read_bytes()) + assert report.valid and report.document is not None + with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): + system = GenericFluidSystem( + compile_system_xml_network(report.document) + ) + + def fail_integration(**_options): + self.assertIsNotNone(system._ode_tangent_provider) + raise RuntimeError("injected integration failure") + + with patch.dict( + os.environ, + {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, + ), patch( + "app.simulation.systems.generic.integrate_ode", + side_effect=fail_integration, + ), self.assertRaisesRegex(RuntimeError, "injected integration failure"): + system.simulate( + SolveIVPConfig(t_stop=1.0e-3, method="BDF"), + sample_step=1.0e-3, + ) + + self.assertIsNone(system._ode_tangent_provider) + + +if __name__ == "__main__": + unittest.main()